diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0de2244..de4ed11 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,7 +5,7 @@ "tasks": [ { "type": "npm", - "script": "bundle", + "script": "compile", "problemMatcher": "$tsc-watch", "isBackground": true, "presentation": { diff --git a/README.md b/README.md index 051a22e..d3246ea 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,5 @@ | `checkForUpdates` | `boolean` | | `true` | Check if there is a newer version of Task on startup. | | `doubleClickTimeout` | `number` | | `0` | Time in milliseconds to consider a double-click. 0 disables double-click to run. 500 is a good starting point if you want to enable it. | | `tree.nesting` | `boolean` | | `true` | Whether to nest tasks by their namespace in the tree view. | +| `tree.status` | `boolean` | | `false` | Whether to show the status of tasks in the tree view (may be slow on large Taskfiles). | | `tree.sort` | `sort` | `default`, `alphanumeric`, `none` | `"default"` | The order in which to display tasks in the tree view. | diff --git a/package.json b/package.json index 72caa59..0c3fd54 100644 --- a/package.json +++ b/package.json @@ -263,6 +263,16 @@ "highContrast": "#ff9b05", "highContrastLight": "#ff9b05" } + }, + { + "id": "vscodetask.primaryColor", + "description": "Color for primary elements.", + "defaults": { + "dark": "#43aba2", + "light": "#43aba2", + "highContrast": "#43aba2", + "highContrastLight": "#43aba2" + } } ], "configuration": { @@ -314,6 +324,11 @@ "default": true, "description": "Whether to nest tasks by their namespace in the tree view." }, + "status": { + "type": "boolean", + "default": false, + "description": "Whether to show the status of tasks in the tree view (may be slow on large Taskfiles)." + }, "sort": { "type": "string", "enum": [ diff --git a/src/elements/activityBar.ts b/src/elements/activityBar.ts index 325d847..cf00eb7 100644 --- a/src/elements/activityBar.ts +++ b/src/elements/activityBar.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { TaskTreeDataProvider } from '../providers/taskTreeDataProvider.js'; -import { Taskfile } from '../models/taskfile.js'; +import { Namespace } from '../models/models.js'; export class ActivityBar { private _provider: TaskTreeDataProvider; @@ -16,12 +16,7 @@ export class ActivityBar { }); } - public setTreeNesting(enabled: boolean) { - this._provider.setTreeNesting(enabled); - this._provider.refresh(); - } - - public refresh(taskfiles?: Taskfile[]) { - this._provider.refresh(taskfiles); + public refresh(taskfiles?: Namespace[], nesting?: boolean): void { + this._provider.refresh(taskfiles, nesting); } } diff --git a/src/elements/quickPickItem.ts b/src/elements/quickPickItem.ts index 62ef52e..ce45bf7 100644 --- a/src/elements/quickPickItem.ts +++ b/src/elements/quickPickItem.ts @@ -1,15 +1,15 @@ import * as vscode from 'vscode'; -import { Taskfile, Task } from '../models/taskfile.js'; +import { Namespace, Task } from '../models/models.js'; export class QuickPickTaskItem implements vscode.QuickPickItem { - constructor(taskfile: Taskfile, task: Task) { - this.taskfile = taskfile; + constructor(namespace: Namespace, task: Task) { + this.namespace = namespace; this.task = task; this.label = task.name; this.description = task.desc; this.kind = vscode.QuickPickItemKind.Default; } - taskfile: Taskfile; + namespace: Namespace; task: Task; label: string; description: string; @@ -17,8 +17,8 @@ export class QuickPickTaskItem implements vscode.QuickPickItem { } export class QuickPickTaskSeparator implements vscode.QuickPickItem { - constructor(taskfile: Taskfile) { - this.label = taskfile.location; + constructor(namespace: Namespace) { + this.label = namespace.location; this.kind = vscode.QuickPickItemKind.Separator; } label: string; diff --git a/src/elements/treeItem.ts b/src/elements/treeItem.ts index fe0f5b0..a5a6eab 100644 --- a/src/elements/treeItem.ts +++ b/src/elements/treeItem.ts @@ -1,39 +1,47 @@ import * as vscode from 'vscode'; -import { Task } from '../models/taskfile.js'; +import { Namespace, Task } from '../models/models.js'; export type TreeItem = WorkspaceTreeItem | NamespaceTreeItem | TaskTreeItem; export class WorkspaceTreeItem extends vscode.TreeItem { + private static readonly icon = 'folder'; constructor( readonly label: string, readonly workspace: string, - readonly tasks: Task[], + readonly namespace: Namespace, readonly collapsibleState: vscode.TreeItemCollapsibleState, readonly command?: vscode.Command ) { super(label, collapsibleState); this.description = this.workspace; - this.iconPath = new vscode.ThemeIcon('folder', new vscode.ThemeColor('vscodetask.workspaceIcon')); + this.iconPath = new vscode.ThemeIcon( + WorkspaceTreeItem.icon, + new vscode.ThemeColor('vscodetask.workspaceIcon') + ); this.contextValue = `workspaceTreeItem`; } } export class NamespaceTreeItem extends vscode.TreeItem { + private static readonly icon = 'symbol-namespace'; constructor( readonly label: string, readonly workspace: string, - readonly namespaceMap: any, - readonly tasks: Task[], + readonly namespace: Namespace, readonly collapsibleState: vscode.TreeItemCollapsibleState, readonly command?: vscode.Command ) { super(label, collapsibleState); - this.iconPath = new vscode.ThemeIcon('symbol-namespace', new vscode.ThemeColor('vscodetask.namespaceIcon')); + this.iconPath = new vscode.ThemeIcon( + NamespaceTreeItem.icon, + new vscode.ThemeColor('vscodetask.namespaceIcon') + ); this.contextValue = `namespaceTreeItem`; } } export class TaskTreeItem extends vscode.TreeItem { + private static readonly icon = 'symbol-function'; constructor( readonly label: string, readonly workspace: string, @@ -43,10 +51,24 @@ export class TaskTreeItem extends vscode.TreeItem { ) { super(label, collapsibleState); this.description = this.task?.desc; - if (this.task.up_to_date) { - this.iconPath = new vscode.ThemeIcon('debug-breakpoint-log-unverified', new vscode.ThemeColor('vscodetask.upToDateIcon')); - } else { - this.iconPath = new vscode.ThemeIcon('debug-breakpoint-data-unverified', new vscode.ThemeColor('vscodetask.outOfDateIcon')); + switch (this.task.up_to_date) { + case true: + this.iconPath = new vscode.ThemeIcon( + TaskTreeItem.icon, + new vscode.ThemeColor('vscodetask.upToDateIcon') + ); + break; + case false: + this.iconPath = new vscode.ThemeIcon( + TaskTreeItem.icon, + new vscode.ThemeColor('vscodetask.outOfDateIcon') + ); + break; + default: + this.iconPath = new vscode.ThemeIcon( + TaskTreeItem.icon, + new vscode.ThemeColor('vscodetask.primaryColor') + ); } this.contextValue = `taskTreeItem`; } diff --git a/src/models/taskfile.ts b/src/models/models.ts similarity index 81% rename from src/models/taskfile.ts rename to src/models/models.ts index 1b6ed66..44eaae1 100644 --- a/src/models/taskfile.ts +++ b/src/models/models.ts @@ -1,9 +1,6 @@ -export type TaskMapping = { - [key: string]: TaskMapping | null; -}; - -export interface Taskfile { +export interface Namespace { tasks: Task[]; + namespaces: Map; location: string; // The location of the actual Taskfile // The vscode workspace directory where the command was executed to find this taskfile // This is where tasks in this taskfile will be executed from. @@ -15,7 +12,7 @@ export interface Task { desc: string; summary: string; // eslint-disable-next-line @typescript-eslint/naming-convention - up_to_date: boolean; + up_to_date: boolean | undefined; location: Location; } diff --git a/src/providers/taskTreeDataProvider.ts b/src/providers/taskTreeDataProvider.ts index 41252b1..c4ab2ab 100644 --- a/src/providers/taskTreeDataProvider.ts +++ b/src/providers/taskTreeDataProvider.ts @@ -1,22 +1,22 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { TreeItem, TaskTreeItem, NamespaceTreeItem, WorkspaceTreeItem } from '../elements/treeItem.js'; -import { Taskfile, Task, TaskMapping } from '../models/taskfile.js'; +import { Namespace, Task } from '../models/models.js'; const namespaceSeparator = ':'; export class TaskTreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - private _taskfiles?: Taskfile[]; - private _treeViewMap: TaskMapping = {}; + private _namespaces?: Namespace[]; + private _nesting: boolean = false; - constructor( - private nestingEnabled: boolean = false - ) { } - - setTreeNesting(enabled: boolean) { - this.nestingEnabled = enabled; + refresh(namespaces?: Namespace[], nesting?: boolean): void { + if (namespaces) { + this._namespaces = namespaces; + } + this._nesting = nesting ?? this._nesting; + this._onDidChangeTreeData.fire(undefined); } getTreeItem(element: TreeItem): vscode.TreeItem { @@ -24,8 +24,6 @@ export class TaskTreeDataProvider implements vscode.TreeDataProvider { } getChildren(parent?: TreeItem): Thenable { - var treeItems: TreeItem[] = []; - // If there are no workspace folders, return an empty array if (vscode.workspace.workspaceFolders === undefined || vscode.workspace.workspaceFolders.length === 0) { return Promise.resolve([]); @@ -38,51 +36,56 @@ export class TaskTreeDataProvider implements vscode.TreeDataProvider { return Promise.resolve(workspaces); } - // If there are no taskfiles, return an empty array - if (!this._taskfiles || this._taskfiles.length === 0) { + // If there are no namespaces, return an empty array + if (!this._namespaces || this._namespaces.length === 0) { return Promise.resolve([]); } - var tasks: Task[] | undefined; - var parentNamespace = ""; - var namespaceMap = this._treeViewMap; - var workspace = ""; - // If there is no parent and exactly one workspace folder or if the parent is a workspace if (!parent && vscode.workspace.workspaceFolders.length === 1) { - tasks = this._taskfiles[0].tasks; - workspace = this._taskfiles[0].workspace ?? ""; + return Promise.resolve(this.createTreeItems( + this._namespaces[0].workspace ?? "", + this._namespaces[0].namespaces, + this._namespaces[0].tasks + )); } - // If there is a parent and it is a workspace - if (parent instanceof WorkspaceTreeItem) { - tasks = parent.tasks; - workspace = parent.workspace; + // If there is a parent and it is a workspace or namespace + if (parent instanceof WorkspaceTreeItem || parent instanceof NamespaceTreeItem) { + return Promise.resolve(this.createTreeItems( + parent.workspace, + parent.namespace.namespaces, + parent.namespace.tasks + )); } - // If there is a parent and it is a namespace - if (parent instanceof NamespaceTreeItem) { - tasks = parent.tasks; - parentNamespace = parent.label; - namespaceMap = parent.namespaceMap; - workspace = parent.workspace; - } + return Promise.resolve([]); + } + createTreeItems( + workspace: string, + namespaces: Map, + tasks: Task[] + ): TreeItem[] { + var treeItems: TreeItem[] = []; - if (tasks === undefined) { - return Promise.resolve([]); + // Add each namespace to the tree + if (namespaces) { + for (const [key, namespace] of Object.entries(namespaces)){ + treeItems = treeItems.concat(new NamespaceTreeItem( + key, + workspace, + namespace, + vscode.TreeItemCollapsibleState.Collapsed + )); + } } - let namespaceTreeItems = new Map(); - let taskTreeItems: TaskTreeItem[] = []; - tasks.forEach(task => { - let taskName = task.name.split(":").pop() ?? task.name; - let namespacePath = trimParentNamespace(task.name, parentNamespace); - let namespaceName = getNamespaceName(namespacePath); - - if (taskName in namespaceMap) { - let item = new TaskTreeItem( - task.name.split(namespaceSeparator).pop() ?? task.name, + // Add each task to the tree + if (tasks) { + for (const task of tasks) { + treeItems = treeItems.concat(new TaskTreeItem( + this._nesting ? task.name.split(namespaceSeparator).pop() ?? task.name : task.name, workspace, task, vscode.TreeItemCollapsibleState.None, @@ -91,119 +94,25 @@ export class TaskTreeDataProvider implements vscode.TreeDataProvider { title: 'Go to Definition', arguments: [task, true] } - ); - taskTreeItems = taskTreeItems.concat(item); - } - - if (namespaceName in namespaceMap && namespaceMap[namespaceName] !== null) { - let namespaceTreeItem = namespaceTreeItems.get(namespaceName); - - if (namespaceTreeItem === undefined) { - namespaceTreeItem = new NamespaceTreeItem( - namespaceName, - workspace, - namespaceMap[namespaceName], - [], - vscode.TreeItemCollapsibleState.Collapsed - ); - } - namespaceTreeItem.tasks.push(task); - namespaceTreeItems.set(namespaceName, namespaceTreeItem); + )); } + } - }); - - // Add the namespace and tasks to the tree - namespaceTreeItems.forEach(namespace => { - treeItems = treeItems.concat(namespace); - }); - treeItems = treeItems.concat(taskTreeItems); - - return Promise.resolve(treeItems); + return treeItems; } getWorkspaces(): WorkspaceTreeItem[] { let workspaceTreeItems: WorkspaceTreeItem[] = []; - this._taskfiles?.forEach(taskfile => { - let dir = path.dirname(taskfile.location); + this._namespaces?.forEach(namespace => { + let dir = path.dirname(namespace.location); let workspaceTreeItem = new WorkspaceTreeItem( path.basename(dir), dir, - taskfile.tasks, + namespace, vscode.TreeItemCollapsibleState.Expanded ); workspaceTreeItems = workspaceTreeItems.concat(workspaceTreeItem); }); return workspaceTreeItems; } - - refresh(taskfiles?: Taskfile[]): void { - if (taskfiles) { - this._taskfiles = taskfiles; - this._treeViewMap = {}; - - // loop over all of the tasks in all of the task files and map their names into a set - const taskNames = Array.from(new Set( - taskfiles.flatMap(taskfile => - taskfile.tasks.flatMap(task => task.name) - ) - // and sort desc so we know that the namespace reduction sets child objects correctly. - )).sort((a, b) => (a > b ? -1 : 1)); - - taskNames.reduce((acc: any, key: string) => { - const parts = key.split(':'); - let currentLevel = acc; - - parts.forEach((part, index) => { - if (part === "") { - return; - }; - - if (!(part in currentLevel)) { - currentLevel[part] = {}; - if (index === parts.length - 1) { - currentLevel[part] = null; - } - } - - currentLevel = currentLevel[part] as TaskMapping; - }); - - return acc; - }, this._treeViewMap); - } - this._onDidChangeTreeData.fire(undefined); - } -} - -function getFullNamespacePath(task: Task): string { - // If the task has no namespace, return undefined - if (!task.name.includes(namespaceSeparator)) { - return ""; - } - // Return the task's namespace by removing the last element - return task.name.substring(0, task.name.lastIndexOf(namespaceSeparator)); -} - -function trimParentNamespace(namespace: string, parentNamespace: string): string { - if (parentNamespace === "") { - return namespace; - } - - const index = namespace.indexOf(parentNamespace + namespaceSeparator); - - if (index === -1) { - return namespace; - } - - return namespace.substring(index + parentNamespace.length + 1); -} - -function getNamespaceName(namespacePath: string): string { - // If the namespace has no separator, return the namespace - if (!namespacePath.includes(namespaceSeparator)) { - return namespacePath; - } - // Return the first element of the namespace - return namespacePath.substring(0, namespacePath.indexOf(namespaceSeparator)); } diff --git a/src/services/taskfile.ts b/src/services/taskfile.ts index 0ac50d2..0017745 100644 --- a/src/services/taskfile.ts +++ b/src/services/taskfile.ts @@ -5,7 +5,7 @@ import { Octokit } from 'octokit'; import * as path from 'path'; import * as semver from 'semver'; import * as vscode from 'vscode'; -import { Taskfile, Task } from '../models/taskfile.js'; +import { Namespace, Task } from '../models/models.js'; import { OutputTo, TerminalClose, TerminalPer, TreeSort, settings } from '../utils/settings.js'; import { log } from '../utils/log.js'; import stripAnsi from 'strip-ansi'; @@ -14,7 +14,7 @@ const octokit = new Octokit(); type ReleaseRequest = Endpoints["GET /repos/{owner}/{repo}/releases/latest"]["parameters"]; type ReleaseResponse = Endpoints["GET /repos/{owner}/{repo}/releases/latest"]["response"]; -const minimumRequiredVersion = '3.24.0'; +const minimumRequiredVersion = '3.45.3'; // General exit codes const errCodeOK = 0; @@ -73,7 +73,7 @@ class TaskfileService { cp.exec(command, { cwd }, (_, stdout: string, stderr: string) => { // If the version is a devel version, ignore all version checks - if (stdout.includes("devel")) { + if (stdout.includes("+")) { log.info("Using development version of task"); this.version = new semver.SemVer("999.0.0"); return resolve("ready"); @@ -177,15 +177,24 @@ class TaskfileService { } } - public async read(dir: string): Promise { + public async read(dir: string, nesting: boolean, status: boolean): Promise { log.info(`Searching for taskfile in: "${dir}"`); return await new Promise((resolve, reject) => { - let additionalFlags = ""; - // Sorting + let flags = [ + "--list-all", + "--json" + ]; + // Optional flags if (settings.tree.sort !== TreeSort.default) { - additionalFlags = ` --sort ${settings.tree.sort}`; + flags.push(`--sort ${settings.tree.sort}`); } - let command = this.command(`--list-all --json${additionalFlags}`); + if (nesting) { + flags.push(`--nested`); + } + if (!status) { + flags.push(`--no-status`); + } + let command = this.command(`${flags.join(' ')}`); cp.exec(command, { cwd: dir }, (err: cp.ExecException | null, stdout: string, stderr: string) => { if (err) { log.error(err); @@ -209,7 +218,7 @@ class TaskfileService { } return resolve(undefined); } - var taskfile: Taskfile = JSON.parse(stdout); + var taskfile: Namespace = JSON.parse(stdout); if (path.dirname(taskfile.location) !== dir) { log.info(`Ignoring taskfile: "${taskfile.location}" (outside of workspace)`); return reject(); diff --git a/src/task.ts b/src/task.ts index a2b26a6..a1a8917 100644 --- a/src/task.ts +++ b/src/task.ts @@ -2,27 +2,31 @@ import * as vscode from 'vscode'; import { QuickPickTaskItem, QuickPickTaskSeparator } from './elements/quickPickItem.js'; import { TaskTreeItem } from './elements/treeItem.js'; import { ActivityBar } from './elements/activityBar.js'; -import { Taskfile, Task } from './models/taskfile.js'; +import { Namespace, Task } from './models/models.js'; import { taskfileSvc } from './services/taskfile.js'; import { log } from './utils/log.js'; import { settings, UpdateOn } from './utils/settings.js'; export class TaskExtension { - private _taskfiles: Taskfile[] = []; + private _taskfiles: Namespace[] = []; private _activityBar: ActivityBar; private _watcher: vscode.FileSystemWatcher; private _changeTimeout: NodeJS.Timeout | null = null; + private _nesting: boolean; + private _status: boolean; constructor() { this._activityBar = new ActivityBar(); this._watcher = vscode.workspace.createFileSystemWatcher("**/*.{yml,yaml}"); - this.setTreeNesting(settings.tree.nesting); + this._nesting = settings.tree.nesting; + this._status = settings.tree.status; + vscode.commands.executeCommand('setContext', 'vscode-task:treeNesting', this._nesting); } public async update(checkForUpdates?: boolean): Promise { // Do version checks await taskfileSvc.checkInstallation(checkForUpdates).then( - (status): Promise[]> => { + (status): Promise[]> => { // Set the status vscode.commands.executeCommand('setContext', 'vscode-task:status', status); @@ -33,9 +37,9 @@ export class TaskExtension { } // Read taskfiles - let p: Promise[] = []; + let p: Promise[] = []; vscode.workspace.workspaceFolders?.forEach((folder) => { - p.push(taskfileSvc.read(folder.uri.fsPath)); + p.push(taskfileSvc.read(folder.uri.fsPath, this._nesting, this._status)); }); return Promise.allSettled(p); @@ -61,14 +65,15 @@ export class TaskExtension { public async refresh(checkForUpdates?: boolean): Promise { await this.update(checkForUpdates).then(() => { - this._activityBar.refresh(this._taskfiles); + this._activityBar.refresh(this._taskfiles, this._nesting); }).catch((err: string) => { log.error(err); }); } public setTreeNesting(enabled: boolean): void { - this._activityBar.setTreeNesting(enabled); + this._nesting = enabled; + this.refresh(); vscode.commands.executeCommand('setContext', 'vscode-task:treeNesting', enabled); } @@ -152,7 +157,7 @@ export class TaskExtension { vscode.window.showQuickPick(items).then((item) => { if (item && item instanceof QuickPickTaskItem) { - taskfileSvc.runTask(item.label, item.taskfile.workspace); + taskfileSvc.runTask(item.label, item.namespace.workspace); } }); })); @@ -177,7 +182,7 @@ export class TaskExtension { return; } if (item && item instanceof QuickPickTaskItem) { - taskfileSvc.runTask(item.label, item.taskfile.workspace, cliArgsInput); + taskfileSvc.runTask(item.label, item.namespace.workspace, cliArgsInput); } }); }); @@ -286,6 +291,10 @@ export class TaskExtension { log.info("Detected changes to configuration"); if (event.affectsConfiguration("task")) { settings.update(); + this._nesting = settings.tree.nesting; + this._status = settings.tree.status; + this.refresh(false); + vscode.commands.executeCommand('setContext', 'vscode-task:treeNesting', this._nesting); } } } diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 03e2a9a..89eebae 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -50,6 +50,7 @@ export enum UpdateOn { class TreeSettings { private static _instance: TreeSettings; public nesting!: boolean; + public status!: boolean; public sort!: TreeSort; constructor() { @@ -69,6 +70,7 @@ class TreeSettings { // Set the properties this.nesting = config.get("tree.nesting") ?? true; + this.status = config.get("tree.status") ?? false; this.sort = config.get("tree.sort") ?? TreeSort.default; } }