Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
7
import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js';
8
import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js';
9
import { parse } from '../../../../base/common/jsonc.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
12
import { IFileService } from '../../../../platform/files/common/files.js';
13
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
14
import { ISession } from '../../../services/sessions/common/session.js';
15
import { IJSONEditingService } from '../../../../workbench/services/configuration/common/jsonEditing.js';
16
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
17
import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js';
18
import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.js';
19
import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js';
20
import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js';
21
22
export type TaskStorageTarget = 'user' | 'workspace';
23
type TaskRunOnOption = 'default' | 'folderOpen' | 'worktreeCreated';
24
25
interface ITaskRunOptions {
26
readonly runOn?: TaskRunOnOption;
27
}
28
29
/**
30
* Shape of a single task entry inside tasks.json.
31
*/
32
export interface ITaskEntry {
33
readonly label: string;
34
readonly task?: CommandString;
35
readonly script?: string;
36
readonly type?: string;
37
readonly command?: string;
38
readonly args?: CommandString[];
39
readonly inAgents?: boolean;
40
readonly runOptions?: ITaskRunOptions;
41
readonly windows?: { command?: string; args?: CommandString[] };
42
readonly osx?: { command?: string; args?: CommandString[] };
43
readonly linux?: { command?: string; args?: CommandString[] };
44
readonly [key: string]: unknown;
45
}
46
47
export interface INonSessionTaskEntry {
48
readonly task: ITaskEntry;
49
readonly target: TaskStorageTarget;
50
}
51
52
/**
53
* A session task together with the storage target it was loaded from.
54
*/
55
export interface ISessionTaskWithTarget {
56
readonly task: ITaskEntry;
57
readonly target: TaskStorageTarget;
58
}
59
60
interface ITasksJson {
61
version?: string;
62
tasks?: ITaskEntry[];
63
}
64
65
export interface ISessionsConfigurationService {
66
readonly _serviceBrand: undefined;
67
68
/**
69
* Observable list of tasks with `inAgents: true`, automatically
70
* updated when the tasks.json file changes. Each entry includes the
71
* storage target the task was loaded from.
72
*/
73
getSessionTasks(session: ISession): IObservable<readonly ISessionTaskWithTarget[]>;
74
75
/**
76
* Returns tasks that do NOT have `inAgents: true` — used as
77
* suggestions in the "Add Run Action" picker.
78
*/
79
getNonSessionTasks(session: ISession): Promise<readonly INonSessionTaskEntry[]>;
80
81
/**
82
* Sets `inAgents: true` on an existing task (identified by label),
83
* updating it in place in its tasks.json.
84
*/
85
addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<void>;
86
87
/**
88
* Creates a new shell task with `inAgents: true` and writes it to
89
* the appropriate tasks.json (user or workspace).
90
*/
91
createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<ITaskEntry | undefined>;
92
93
/**
94
* Updates an existing task entry, optionally moving it between user and
95
* workspace storage.
96
*/
97
updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise<void>;
98
99
/**
100
* Removes an existing task entry from its tasks.json.
101
*/
102
removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise<void>;
103
104
/**
105
* Runs a task via the task service, looking it up by label in the
106
* workspace folder corresponding to the session worktree.
107
*/
108
runTask(task: ITaskEntry, session: ISession): Promise<void>;
109
110
/**
111
* Observable label of the pinned task for the given repository.
112
*/
113
getPinnedTaskLabel(repository: URI | undefined): IObservable<string | undefined>;
114
115
/**
116
* Sets or clears the pinned task for the given repository.
117
*/
118
setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void;
119
}
120
121
export const ISessionsConfigurationService = createDecorator<ISessionsConfigurationService>('sessionsConfigurationService');
122
123
export class SessionsConfigurationService extends Disposable implements ISessionsConfigurationService {
124
125
declare readonly _serviceBrand: undefined;
126
127
private static readonly _PINNED_TASK_LABELS_KEY = 'agentSessions.pinnedTaskLabels';
128
private readonly _sessionTasks = observableValue<readonly ISessionTaskWithTarget[]>(this, []);
129
private readonly _fileWatcher = this._register(new MutableDisposable());
130
private readonly _pinnedTaskLabels: Map<string, string>;
131
private readonly _pinnedTaskObservables = new Map<string, ReturnType<typeof observableValue<string | undefined>>>();
132
133
private _watchedResource: URI | undefined;
134
private _lastRefreshedFolder: URI | undefined;
135
136
constructor(
137
@IFileService private readonly _fileService: IFileService,
138
@IJSONEditingService private readonly _jsonEditingService: IJSONEditingService,
139
@IPreferencesService private readonly _preferencesService: IPreferencesService,
140
@ITaskService private readonly _taskService: ITaskService,
141
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
142
@IStorageService private readonly _storageService: IStorageService,
143
) {
144
super();
145
this._pinnedTaskLabels = this._loadPinnedTaskLabels();
146
}
147
148
getSessionTasks(session: ISession): IObservable<readonly ISessionTaskWithTarget[]> {
149
const repo = this._getSessionRepo(session);
150
const folder = repo?.workingDirectory ?? repo?.uri;
151
if (folder) {
152
this._ensureFileWatch(folder);
153
}
154
// Trigger initial read only when the folder changes; the file watcher handles subsequent updates
155
if (!isEqual(this._lastRefreshedFolder, folder)) {
156
this._lastRefreshedFolder = folder;
157
this._refreshSessionTasks(folder);
158
}
159
return this._sessionTasks;
160
}
161
162
async getNonSessionTasks(session: ISession): Promise<readonly INonSessionTaskEntry[]> {
163
const result: INonSessionTaskEntry[] = [];
164
165
const workspaceUri = this._getTasksJsonUri(session, 'workspace');
166
if (workspaceUri) {
167
const workspaceJson = await this._readTasksJson(workspaceUri);
168
for (const task of workspaceJson.tasks ?? []) {
169
if (!task.inAgents && this._isSupportedTask(task)) {
170
result.push({ task, target: 'workspace' });
171
}
172
}
173
}
174
175
const userUri = this._getTasksJsonUri(session, 'user');
176
if (userUri) {
177
const userJson = await this._readTasksJson(userUri);
178
for (const task of userJson.tasks ?? []) {
179
if (!task.inAgents && this._isSupportedTask(task)) {
180
result.push({ task, target: 'user' });
181
}
182
}
183
}
184
185
return result;
186
}
187
188
async addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<void> {
189
const tasksJsonUri = this._getTasksJsonUri(session, target);
190
if (!tasksJsonUri) {
191
return;
192
}
193
194
const tasksJson = await this._readTasksJson(tasksJsonUri);
195
const tasks = tasksJson.tasks ?? [];
196
const index = tasks.findIndex(t => t.label === task.label);
197
if (index === -1) {
198
return;
199
}
200
201
const edits: { path: (string | number)[]; value: unknown }[] = [
202
{ path: ['tasks', index, 'inAgents'], value: true },
203
];
204
205
if (options) {
206
edits.push({
207
path: ['tasks', index, 'runOptions'],
208
value: options.runOn && options.runOn !== 'default' ? { runOn: options.runOn } : undefined,
209
});
210
}
211
212
await this._jsonEditingService.write(tasksJsonUri, edits, true);
213
}
214
215
async createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<ITaskEntry | undefined> {
216
const tasksJsonUri = this._getTasksJsonUri(session, target);
217
if (!tasksJsonUri) {
218
return undefined;
219
}
220
221
const tasksJson = await this._readTasksJson(tasksJsonUri);
222
const tasks = tasksJson.tasks ?? [];
223
const resolvedLabel = label?.trim() || command;
224
const newTask: ITaskEntry = {
225
label: resolvedLabel,
226
type: 'shell',
227
command,
228
inAgents: true,
229
...(options?.runOn && options.runOn !== 'default' ? { runOptions: { runOn: options.runOn } } : {}),
230
};
231
232
await this._jsonEditingService.write(tasksJsonUri, [
233
{ path: ['version'], value: tasksJson.version ?? '2.0.0' },
234
{ path: ['tasks'], value: [...tasks, newTask] }
235
], true);
236
237
return newTask;
238
}
239
240
async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise<void> {
241
const currentTasksJsonUri = this._getTasksJsonUri(session, currentTarget);
242
const newTasksJsonUri = this._getTasksJsonUri(session, newTarget);
243
if (!currentTasksJsonUri || !newTasksJsonUri) {
244
return;
245
}
246
247
const currentTasksJson = await this._readTasksJson(currentTasksJsonUri);
248
const currentTasks = currentTasksJson.tasks ?? [];
249
const currentIndex = currentTasks.findIndex(task => task.label === originalTaskLabel);
250
if (currentIndex === -1) {
251
return;
252
}
253
254
if (currentTasksJsonUri.toString() === newTasksJsonUri.toString()) {
255
const updatedTasks = currentTasks.map((task, i) => i === currentIndex ? updatedTask : task);
256
await this._jsonEditingService.write(currentTasksJsonUri, [
257
{ path: ['tasks'], value: updatedTasks },
258
], true);
259
} else {
260
const newTasksJson = await this._readTasksJson(newTasksJsonUri);
261
const newTasks = newTasksJson.tasks ?? [];
262
263
await this._jsonEditingService.write(currentTasksJsonUri, [
264
{ path: ['tasks'], value: currentTasks.filter((_, taskIndex) => taskIndex !== currentIndex) },
265
], true);
266
267
await this._jsonEditingService.write(newTasksJsonUri, [
268
{ path: ['version'], value: newTasksJson.version ?? '2.0.0' },
269
{ path: ['tasks'], value: [...newTasks, updatedTask] },
270
], true);
271
}
272
273
const repoUri = this._getSessionRepo(session)?.uri;
274
if (repoUri) {
275
const key = repoUri.toString();
276
if (this._pinnedTaskLabels.get(key) === originalTaskLabel) {
277
this._setPinnedTaskLabelForKey(key, updatedTask.label);
278
}
279
}
280
}
281
282
async removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise<void> {
283
const tasksJsonUri = this._getTasksJsonUri(session, target);
284
if (!tasksJsonUri) {
285
return;
286
}
287
288
const tasksJson = await this._readTasksJson(tasksJsonUri);
289
const tasks = tasksJson.tasks ?? [];
290
const index = tasks.findIndex(t => t.label === taskLabel);
291
if (index === -1) {
292
return;
293
}
294
295
await this._jsonEditingService.write(tasksJsonUri, [
296
{ path: ['tasks'], value: tasks.filter((_, taskIndex) => taskIndex !== index) },
297
], true);
298
299
const repoUri = this._getSessionRepo(session)?.uri;
300
if (repoUri) {
301
const key = repoUri.toString();
302
if (this._pinnedTaskLabels.get(key) === taskLabel) {
303
this._setPinnedTaskLabelForKey(key, undefined);
304
}
305
}
306
}
307
308
async runTask(task: ITaskEntry, session: ISession): Promise<void> {
309
const repo = this._getSessionRepo(session);
310
const cwd = repo?.workingDirectory ?? repo?.uri;
311
if (!cwd) {
312
return;
313
}
314
315
const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd);
316
if (!workspaceFolder) {
317
return;
318
}
319
320
const resolvedTask = await this._taskService.getTask(workspaceFolder, task.label);
321
if (!resolvedTask) {
322
return;
323
}
324
325
await this._taskService.run(resolvedTask, undefined, TaskRunSource.User);
326
}
327
328
getPinnedTaskLabel(repository: URI | undefined): IObservable<string | undefined> {
329
if (!repository) {
330
return observableValue('pinnedTaskLabel', undefined);
331
}
332
333
const key = repository.toString();
334
let obs = this._pinnedTaskObservables.get(key);
335
if (!obs) {
336
obs = observableValue('pinnedTaskLabel', this._pinnedTaskLabels.get(key));
337
this._pinnedTaskObservables.set(key, obs);
338
}
339
return obs;
340
}
341
342
setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void {
343
if (!repository) {
344
return;
345
}
346
347
this._setPinnedTaskLabelForKey(repository.toString(), taskLabel);
348
}
349
350
// --- private helpers ---
351
352
private _getSessionRepo(session: ISession) {
353
return session.workspace.get()?.repositories[0];
354
}
355
356
private _getTasksJsonUri(session: ISession, target: TaskStorageTarget): URI | undefined {
357
if (target === 'workspace') {
358
const repo = this._getSessionRepo(session);
359
const folder = repo?.workingDirectory ?? repo?.uri;
360
return folder ? joinPath(folder, '.vscode', 'tasks.json') : undefined;
361
}
362
return joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');
363
}
364
365
private async _readTasksJson(uri: URI): Promise<ITasksJson> {
366
try {
367
const content = await this._fileService.readFile(uri);
368
return parse<ITasksJson>(content.value.toString());
369
} catch {
370
return {};
371
}
372
}
373
374
private _isSupportedTask(task: ITaskEntry): boolean {
375
return !!task.label;
376
}
377
378
private _ensureFileWatch(folder: URI): void {
379
const tasksUri = joinPath(folder, '.vscode', 'tasks.json');
380
if (this._watchedResource && this._watchedResource.toString() === tasksUri.toString()) {
381
return;
382
}
383
this._watchedResource = tasksUri;
384
385
const disposables = new DisposableStore();
386
387
// Watch workspace tasks.json
388
disposables.add(this._fileService.watch(tasksUri));
389
390
// Also watch user-level tasks.json so that user session tasks changes refresh the observable
391
const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');
392
disposables.add(this._fileService.watch(userUri));
393
394
disposables.add(this._fileService.onDidFilesChange(e => {
395
if (e.affects(tasksUri) || e.affects(userUri)) {
396
this._refreshSessionTasks(folder);
397
}
398
}));
399
400
this._fileWatcher.value = disposables;
401
}
402
403
private async _refreshSessionTasks(folder: URI | undefined): Promise<void> {
404
if (!folder) {
405
transaction(tx => this._sessionTasks.set([], tx));
406
return;
407
}
408
409
const tasksUri = joinPath(folder, '.vscode', 'tasks.json');
410
const tasksJson = await this._readTasksJson(tasksUri);
411
const sessionTasks: ISessionTaskWithTarget[] = (tasksJson.tasks ?? [])
412
.filter(t => t.inAgents && this._isSupportedTask(t))
413
.map(t => ({ task: t, target: 'workspace' as TaskStorageTarget }));
414
415
// Also include user-level session tasks
416
const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');
417
const userJson = await this._readTasksJson(userUri);
418
const userSessionTasks: ISessionTaskWithTarget[] = (userJson.tasks ?? [])
419
.filter(t => t.inAgents && this._isSupportedTask(t))
420
.map(t => ({ task: t, target: 'user' as TaskStorageTarget }));
421
422
transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx));
423
}
424
425
private _loadPinnedTaskLabels(): Map<string, string> {
426
const raw = this._storageService.get(SessionsConfigurationService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION);
427
if (raw) {
428
try {
429
return new Map(Object.entries(JSON.parse(raw)));
430
} catch {
431
// ignore corrupt data
432
}
433
}
434
return new Map();
435
}
436
437
private _savePinnedTaskLabels(): void {
438
this._storageService.store(
439
SessionsConfigurationService._PINNED_TASK_LABELS_KEY,
440
JSON.stringify(Object.fromEntries(this._pinnedTaskLabels)),
441
StorageScope.APPLICATION,
442
StorageTarget.USER
443
);
444
}
445
446
private _setPinnedTaskLabelForKey(key: string, taskLabel: string | undefined): void {
447
if (taskLabel === undefined) {
448
this._pinnedTaskLabels.delete(key);
449
} else {
450
this._pinnedTaskLabels.set(key, taskLabel);
451
}
452
453
this._savePinnedTaskLabels();
454
455
const obs = this._pinnedTaskObservables.get(key);
456
if (obs) {
457
transaction(tx => obs.set(taskLabel, tx));
458
}
459
}
460
}
461
462