Path: blob/main/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';6import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js';7import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js';8import { parse } from '../../../../base/common/jsonc.js';9import { URI } from '../../../../base/common/uri.js';10import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';11import { IFileService } from '../../../../platform/files/common/files.js';12import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';13import { ISession } from '../../../services/sessions/common/session.js';14import { IJSONEditingService } from '../../../../workbench/services/configuration/common/jsonEditing.js';15import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';16import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js';17import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.js';18import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js';19import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js';2021export type TaskStorageTarget = 'user' | 'workspace';22type TaskRunOnOption = 'default' | 'folderOpen' | 'worktreeCreated';2324interface ITaskRunOptions {25readonly runOn?: TaskRunOnOption;26}2728/**29* Shape of a single task entry inside tasks.json.30*/31export interface ITaskEntry {32readonly label: string;33readonly task?: CommandString;34readonly script?: string;35readonly type?: string;36readonly command?: string;37readonly args?: CommandString[];38readonly inAgents?: boolean;39readonly runOptions?: ITaskRunOptions;40readonly windows?: { command?: string; args?: CommandString[] };41readonly osx?: { command?: string; args?: CommandString[] };42readonly linux?: { command?: string; args?: CommandString[] };43readonly [key: string]: unknown;44}4546export interface INonSessionTaskEntry {47readonly task: ITaskEntry;48readonly target: TaskStorageTarget;49}5051/**52* A session task together with the storage target it was loaded from.53*/54export interface ISessionTaskWithTarget {55readonly task: ITaskEntry;56readonly target: TaskStorageTarget;57}5859interface ITasksJson {60version?: string;61tasks?: ITaskEntry[];62}6364export interface ISessionsConfigurationService {65readonly _serviceBrand: undefined;6667/**68* Observable list of tasks with `inAgents: true`, automatically69* updated when the tasks.json file changes. Each entry includes the70* storage target the task was loaded from.71*/72getSessionTasks(session: ISession): IObservable<readonly ISessionTaskWithTarget[]>;7374/**75* Returns tasks that do NOT have `inAgents: true` — used as76* suggestions in the "Add Run Action" picker.77*/78getNonSessionTasks(session: ISession): Promise<readonly INonSessionTaskEntry[]>;7980/**81* Sets `inAgents: true` on an existing task (identified by label),82* updating it in place in its tasks.json.83*/84addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<void>;8586/**87* Creates a new shell task with `inAgents: true` and writes it to88* the appropriate tasks.json (user or workspace).89*/90createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<ITaskEntry | undefined>;9192/**93* Updates an existing task entry, optionally moving it between user and94* workspace storage.95*/96updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise<void>;9798/**99* Removes an existing task entry from its tasks.json.100*/101removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise<void>;102103/**104* Runs a task via the task service, looking it up by label in the105* workspace folder corresponding to the session worktree.106*/107runTask(task: ITaskEntry, session: ISession): Promise<void>;108109/**110* Observable label of the pinned task for the given repository.111*/112getPinnedTaskLabel(repository: URI | undefined): IObservable<string | undefined>;113114/**115* Sets or clears the pinned task for the given repository.116*/117setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void;118}119120export const ISessionsConfigurationService = createDecorator<ISessionsConfigurationService>('sessionsConfigurationService');121122export class SessionsConfigurationService extends Disposable implements ISessionsConfigurationService {123124declare readonly _serviceBrand: undefined;125126private static readonly _PINNED_TASK_LABELS_KEY = 'agentSessions.pinnedTaskLabels';127private readonly _sessionTasks = observableValue<readonly ISessionTaskWithTarget[]>(this, []);128private readonly _fileWatcher = this._register(new MutableDisposable());129private readonly _pinnedTaskLabels: Map<string, string>;130private readonly _pinnedTaskObservables = new Map<string, ReturnType<typeof observableValue<string | undefined>>>();131132private _watchedResource: URI | undefined;133private _lastRefreshedFolder: URI | undefined;134135constructor(136@IFileService private readonly _fileService: IFileService,137@IJSONEditingService private readonly _jsonEditingService: IJSONEditingService,138@IPreferencesService private readonly _preferencesService: IPreferencesService,139@ITaskService private readonly _taskService: ITaskService,140@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,141@IStorageService private readonly _storageService: IStorageService,142) {143super();144this._pinnedTaskLabels = this._loadPinnedTaskLabels();145}146147getSessionTasks(session: ISession): IObservable<readonly ISessionTaskWithTarget[]> {148const repo = this._getSessionRepo(session);149const folder = repo?.workingDirectory ?? repo?.uri;150if (folder) {151this._ensureFileWatch(folder);152}153// Trigger initial read only when the folder changes; the file watcher handles subsequent updates154if (!isEqual(this._lastRefreshedFolder, folder)) {155this._lastRefreshedFolder = folder;156this._refreshSessionTasks(folder);157}158return this._sessionTasks;159}160161async getNonSessionTasks(session: ISession): Promise<readonly INonSessionTaskEntry[]> {162const result: INonSessionTaskEntry[] = [];163164const workspaceUri = this._getTasksJsonUri(session, 'workspace');165if (workspaceUri) {166const workspaceJson = await this._readTasksJson(workspaceUri);167for (const task of workspaceJson.tasks ?? []) {168if (!task.inAgents && this._isSupportedTask(task)) {169result.push({ task, target: 'workspace' });170}171}172}173174const userUri = this._getTasksJsonUri(session, 'user');175if (userUri) {176const userJson = await this._readTasksJson(userUri);177for (const task of userJson.tasks ?? []) {178if (!task.inAgents && this._isSupportedTask(task)) {179result.push({ task, target: 'user' });180}181}182}183184return result;185}186187async addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<void> {188const tasksJsonUri = this._getTasksJsonUri(session, target);189if (!tasksJsonUri) {190return;191}192193const tasksJson = await this._readTasksJson(tasksJsonUri);194const tasks = tasksJson.tasks ?? [];195const index = tasks.findIndex(t => t.label === task.label);196if (index === -1) {197return;198}199200const edits: { path: (string | number)[]; value: unknown }[] = [201{ path: ['tasks', index, 'inAgents'], value: true },202];203204if (options) {205edits.push({206path: ['tasks', index, 'runOptions'],207value: options.runOn && options.runOn !== 'default' ? { runOn: options.runOn } : undefined,208});209}210211await this._jsonEditingService.write(tasksJsonUri, edits, true);212}213214async createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise<ITaskEntry | undefined> {215const tasksJsonUri = this._getTasksJsonUri(session, target);216if (!tasksJsonUri) {217return undefined;218}219220const tasksJson = await this._readTasksJson(tasksJsonUri);221const tasks = tasksJson.tasks ?? [];222const resolvedLabel = label?.trim() || command;223const newTask: ITaskEntry = {224label: resolvedLabel,225type: 'shell',226command,227inAgents: true,228...(options?.runOn && options.runOn !== 'default' ? { runOptions: { runOn: options.runOn } } : {}),229};230231await this._jsonEditingService.write(tasksJsonUri, [232{ path: ['version'], value: tasksJson.version ?? '2.0.0' },233{ path: ['tasks'], value: [...tasks, newTask] }234], true);235236return newTask;237}238239async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise<void> {240const currentTasksJsonUri = this._getTasksJsonUri(session, currentTarget);241const newTasksJsonUri = this._getTasksJsonUri(session, newTarget);242if (!currentTasksJsonUri || !newTasksJsonUri) {243return;244}245246const currentTasksJson = await this._readTasksJson(currentTasksJsonUri);247const currentTasks = currentTasksJson.tasks ?? [];248const currentIndex = currentTasks.findIndex(task => task.label === originalTaskLabel);249if (currentIndex === -1) {250return;251}252253if (currentTasksJsonUri.toString() === newTasksJsonUri.toString()) {254const updatedTasks = currentTasks.map((task, i) => i === currentIndex ? updatedTask : task);255await this._jsonEditingService.write(currentTasksJsonUri, [256{ path: ['tasks'], value: updatedTasks },257], true);258} else {259const newTasksJson = await this._readTasksJson(newTasksJsonUri);260const newTasks = newTasksJson.tasks ?? [];261262await this._jsonEditingService.write(currentTasksJsonUri, [263{ path: ['tasks'], value: currentTasks.filter((_, taskIndex) => taskIndex !== currentIndex) },264], true);265266await this._jsonEditingService.write(newTasksJsonUri, [267{ path: ['version'], value: newTasksJson.version ?? '2.0.0' },268{ path: ['tasks'], value: [...newTasks, updatedTask] },269], true);270}271272const repoUri = this._getSessionRepo(session)?.uri;273if (repoUri) {274const key = repoUri.toString();275if (this._pinnedTaskLabels.get(key) === originalTaskLabel) {276this._setPinnedTaskLabelForKey(key, updatedTask.label);277}278}279}280281async removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise<void> {282const tasksJsonUri = this._getTasksJsonUri(session, target);283if (!tasksJsonUri) {284return;285}286287const tasksJson = await this._readTasksJson(tasksJsonUri);288const tasks = tasksJson.tasks ?? [];289const index = tasks.findIndex(t => t.label === taskLabel);290if (index === -1) {291return;292}293294await this._jsonEditingService.write(tasksJsonUri, [295{ path: ['tasks'], value: tasks.filter((_, taskIndex) => taskIndex !== index) },296], true);297298const repoUri = this._getSessionRepo(session)?.uri;299if (repoUri) {300const key = repoUri.toString();301if (this._pinnedTaskLabels.get(key) === taskLabel) {302this._setPinnedTaskLabelForKey(key, undefined);303}304}305}306307async runTask(task: ITaskEntry, session: ISession): Promise<void> {308const repo = this._getSessionRepo(session);309const cwd = repo?.workingDirectory ?? repo?.uri;310if (!cwd) {311return;312}313314const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd);315if (!workspaceFolder) {316return;317}318319const resolvedTask = await this._taskService.getTask(workspaceFolder, task.label);320if (!resolvedTask) {321return;322}323324await this._taskService.run(resolvedTask, undefined, TaskRunSource.User);325}326327getPinnedTaskLabel(repository: URI | undefined): IObservable<string | undefined> {328if (!repository) {329return observableValue('pinnedTaskLabel', undefined);330}331332const key = repository.toString();333let obs = this._pinnedTaskObservables.get(key);334if (!obs) {335obs = observableValue('pinnedTaskLabel', this._pinnedTaskLabels.get(key));336this._pinnedTaskObservables.set(key, obs);337}338return obs;339}340341setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void {342if (!repository) {343return;344}345346this._setPinnedTaskLabelForKey(repository.toString(), taskLabel);347}348349// --- private helpers ---350351private _getSessionRepo(session: ISession) {352return session.workspace.get()?.repositories[0];353}354355private _getTasksJsonUri(session: ISession, target: TaskStorageTarget): URI | undefined {356if (target === 'workspace') {357const repo = this._getSessionRepo(session);358const folder = repo?.workingDirectory ?? repo?.uri;359return folder ? joinPath(folder, '.vscode', 'tasks.json') : undefined;360}361return joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');362}363364private async _readTasksJson(uri: URI): Promise<ITasksJson> {365try {366const content = await this._fileService.readFile(uri);367return parse<ITasksJson>(content.value.toString());368} catch {369return {};370}371}372373private _isSupportedTask(task: ITaskEntry): boolean {374return !!task.label;375}376377private _ensureFileWatch(folder: URI): void {378const tasksUri = joinPath(folder, '.vscode', 'tasks.json');379if (this._watchedResource && this._watchedResource.toString() === tasksUri.toString()) {380return;381}382this._watchedResource = tasksUri;383384const disposables = new DisposableStore();385386// Watch workspace tasks.json387disposables.add(this._fileService.watch(tasksUri));388389// Also watch user-level tasks.json so that user session tasks changes refresh the observable390const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');391disposables.add(this._fileService.watch(userUri));392393disposables.add(this._fileService.onDidFilesChange(e => {394if (e.affects(tasksUri) || e.affects(userUri)) {395this._refreshSessionTasks(folder);396}397}));398399this._fileWatcher.value = disposables;400}401402private async _refreshSessionTasks(folder: URI | undefined): Promise<void> {403if (!folder) {404transaction(tx => this._sessionTasks.set([], tx));405return;406}407408const tasksUri = joinPath(folder, '.vscode', 'tasks.json');409const tasksJson = await this._readTasksJson(tasksUri);410const sessionTasks: ISessionTaskWithTarget[] = (tasksJson.tasks ?? [])411.filter(t => t.inAgents && this._isSupportedTask(t))412.map(t => ({ task: t, target: 'workspace' as TaskStorageTarget }));413414// Also include user-level session tasks415const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json');416const userJson = await this._readTasksJson(userUri);417const userSessionTasks: ISessionTaskWithTarget[] = (userJson.tasks ?? [])418.filter(t => t.inAgents && this._isSupportedTask(t))419.map(t => ({ task: t, target: 'user' as TaskStorageTarget }));420421transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx));422}423424private _loadPinnedTaskLabels(): Map<string, string> {425const raw = this._storageService.get(SessionsConfigurationService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION);426if (raw) {427try {428return new Map(Object.entries(JSON.parse(raw)));429} catch {430// ignore corrupt data431}432}433return new Map();434}435436private _savePinnedTaskLabels(): void {437this._storageService.store(438SessionsConfigurationService._PINNED_TASK_LABELS_KEY,439JSON.stringify(Object.fromEntries(this._pinnedTaskLabels)),440StorageScope.APPLICATION,441StorageTarget.USER442);443}444445private _setPinnedTaskLabelForKey(key: string, taskLabel: string | undefined): void {446if (taskLabel === undefined) {447this._pinnedTaskLabels.delete(key);448} else {449this._pinnedTaskLabels.set(key, taskLabel);450}451452this._savePinnedTaskLabels();453454const obs = this._pinnedTaskObservables.get(key);455if (obs) {456transaction(tx => obs.set(taskLabel, tx));457}458}459}460461462