diff --git a/package.json b/package.json index 3589a9a..812a3e2 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,12 @@ { "command": "task-explorer.favorite-task", "title": "Add task to favorites", - "icon": "$(extensions-star-empty)" + "icon": "$(extensions-star-full)" }, { "command": "task-explorer.unfavorite-task", "title": "Remove task from favorites", - "icon": "$(extensions-star-full)" + "icon": "$(extensions-star-empty)" } ], "viewsContainers": { @@ -81,6 +81,11 @@ "command": "task-explorer.favorite-task", "when": "view == task-explorer && viewItem == taskItem", "group": "inline" + }, + { + "command": "task-explorer.unfavorite-task", + "when": "view == task-explorer && viewItem == favoriteItem", + "group": "inline" } ] }, diff --git a/src/commands.ts b/src/commands.ts index 6166549..a6a3af9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,9 +1,11 @@ -import { commands, window } from 'vscode' +import { commands } from 'vscode' import { Container, EXTENSION_ID } from './extension' +import { TaskItem } from './services/task-data-provider' export default function registerCommands(ioc: Container) { const context = ioc.resolve('context') const taskDataProvider = ioc.resolve('taskDataProvider') + const favorites = ioc.resolve('favorites') const { subscriptions, @@ -16,11 +18,11 @@ export default function registerCommands(ioc: Container) { ), commands.registerCommand( `${EXTENSION_ID}.favorite-task`, - () => window.showInformationMessage('Favoriting tasks in currently work-in-progress.') + (item: TaskItem) => favorites.add(item) ), commands.registerCommand( `${EXTENSION_ID}.unfavorite-task`, - () => window.showInformationMessage('Favoriting tasks in currently work-in-progress.') + (item: TaskItem) => favorites.remove(item) ), ) } diff --git a/src/disposable.ts b/src/disposable.ts new file mode 100644 index 0000000..3fe8f4b --- /dev/null +++ b/src/disposable.ts @@ -0,0 +1,4 @@ + +export interface Disposable { + dispose: () => void | Promise +} diff --git a/src/extension.ts b/src/extension.ts index e3204f4..7247e3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,9 +36,9 @@ export async function activate(context: ExtensionContext): Promise { +export default class Config extends TypedEventEmitter implements Disposable { private delegate: WorkspaceConfiguration @@ -25,8 +26,6 @@ export default class Config extends TypedEventEmitter { this.delegate = workspace.getConfiguration(EXTENSION_ID) - subscriptions.push(this) - // listen for config changes subscriptions.push( workspace.onDidChangeConfiguration(e => { diff --git a/src/services/favorites.ts b/src/services/favorites.ts index fe4fd14..7cf6dc3 100644 --- a/src/services/favorites.ts +++ b/src/services/favorites.ts @@ -1,11 +1,72 @@ -import { ExtensionContext } from 'vscode' +import { ExtensionContext, window } from 'vscode' +import { Disposable } from '../disposable' +import TypedEventEmitter from '../events' +import { EXTENSION_ID } from '../extension' +import { TaskItem } from './task-data-provider' -export class Favorites { +interface LocalEventTypes { + 'change': [] +} + +const storageKey = `${EXTENSION_ID}-favorites` + +export class Favorites extends TypedEventEmitter implements Disposable { private context: ExtensionContext + private items: Set = new Set() + constructor(context: ExtensionContext) { + super() + this.context = context + + this.init() + } + + private notify(): void { + this.emit('change') + } + + private save() { + const ids: string[] = [] + this.items.forEach(v => ids.push(v)) + this.context.workspaceState.update(storageKey, ids) + } + + init(): void { + const items = this.context.workspaceState.get(storageKey) + if (Array.isArray(items)) { + this.items = new Set(items) + console.log(`loaded ${items.length} favorites`) + + this.notify() + } + } + + dispose(): void { + this.emitter.removeAllListeners() + this.save() + } + + list(): Set { + return this.items + } + + add(item: TaskItem): void { + this.items.add(item.id) + this.notify() + this.save() + } + + remove(item: TaskItem): void { + const id = item.id.substring(9) + if (this.items.delete(id)) { + this.notify() + this.save() + } else { + window.showWarningMessage(`item with ID "${item.id}" was not in Set!`) + } } } diff --git a/src/services/task-data-provider.ts b/src/services/task-data-provider.ts index 48b739d..ea9d8d3 100644 --- a/src/services/task-data-provider.ts +++ b/src/services/task-data-provider.ts @@ -3,18 +3,31 @@ import { stat } from 'fs/promises' import { join } from 'path' import { groupBy, identity } from 'remeda' import { Command, Event, EventEmitter, ExtensionContext, ProgressLocation, ProviderResult, Task, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, tasks, window } from 'vscode' -import Config from './config' +import { Disposable } from '../disposable' import { EXTENSION_ID } from '../extension' +import { notEmpty } from '../util' +import Config from './config' +import { Favorites } from './favorites' -function makeTaskId(task: Task): string { +const groupKeyFavorites = 'favorites' + +function makeTaskId(task: Task, isFavorite: boolean): string { const id = `${task.definition.type}-${task.name.replace(/\s+/g, '_').toLocaleLowerCase()}-${task.source}` - return createHash('md5').update(id).digest('hex') + const hash = createHash('md5').update(id).digest('hex') + + if (isFavorite) { + return `favorite-${hash}` + } + + return hash } function makeGroupLabel(label: string): string { switch (label) { case '$composite': return 'Composite Tasks' + case groupKeyFavorites: + return 'Favorite Tasks' default: return label } @@ -53,6 +66,9 @@ export class GroupItem extends TreeItem { case 'shell': this.iconPath = new ThemeIcon('terminal-view-icon') return + case groupKeyFavorites: + this.iconPath = new ThemeIcon('star-full') + return } const file = join(__dirname, '..', 'resources', 'icons', `${name}.svg`) @@ -74,51 +90,66 @@ export class GroupItem extends TreeItem { export class TaskItem extends TreeItem { + readonly task: Task readonly id: string readonly group: string constructor( task: Task, command: Command, + isFavorite: boolean = false, ) { super(task.name, TreeItemCollapsibleState.None) - this.id = makeTaskId(task) - this.group = task.definition.type + this.task = task + this.id = makeTaskId(task, isFavorite) + this.group = isFavorite ? groupKeyFavorites : task.definition.type this.description = task.detail this.command = command } + public get isFavorite() : boolean { + return this.group === groupKeyFavorites + } + contextValue = 'taskItem' } +export class FavoriteItem extends TaskItem { + + constructor( + task: Task, + command: Command, + ) { + super(task, command, true) + } + + contextValue = 'favoriteItem' + +} + type TaskList = Record -export default class TaskDataProvider implements TreeDataProvider { +export default class TaskDataProvider implements TreeDataProvider, Disposable { private config: Config private groups: TreeItem[] = [] private tasks: TaskList = {} + private favorites: Favorites private _onDidChangeTreeData: EventEmitter = new EventEmitter() readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event - constructor(config: Config, context: ExtensionContext) { - const { - subscriptions - } = context - + constructor(config: Config, context: ExtensionContext, favorites: Favorites) { this.config = config + this.favorites = favorites this.config.on('change', () => this.refresh()) + this.favorites.on('change', () => this.refresh()) this.refresh() - - subscriptions.push( - window.registerTreeDataProvider(EXTENSION_ID, this), - ) } getTreeItem(element: TreeItem): TreeItem { @@ -133,6 +164,10 @@ export default class TaskDataProvider implements TreeDataProvider { return this.groups } + dispose(): void { + window.registerTreeDataProvider(EXTENSION_ID, this) + } + private isGroupItem(element?: TreeItem): boolean { return !!element && element.collapsibleState !== TreeItemCollapsibleState.None } @@ -152,7 +187,7 @@ export default class TaskDataProvider implements TreeDataProvider { const excludedGroups = this.config.get('excludeGroups') - return list + const result = list .filter(item => !excludedGroups?.includes(item.definition.type)) .map(item => new TaskItem(item, { command: 'workbench.action.tasks.runTask', @@ -160,6 +195,20 @@ export default class TaskDataProvider implements TreeDataProvider { arguments: this.buildArguments(item), })) .sort((a, b) => (a.label as string).localeCompare(b.label as string)) + + const favorites = Array.from(this.favorites.list()) + .map(id => { + const item = result.find(item => item.id === id) + if (item !== undefined) { + return new FavoriteItem(item.task, item.command!) + } + + return undefined + }) + .filter(notEmpty) + .sort((a, b) => a.task.name.localeCompare(b.task.name)) + + return favorites.concat(result) } async refresh(): Promise { @@ -176,7 +225,16 @@ export default class TaskDataProvider implements TreeDataProvider { item => item.group ) this.groups = Object.keys(this.tasks) - .sort() + .sort((a, b) => { + if (a === groupKeyFavorites) { + return -1 + } + else if (b === groupKeyFavorites) { + return 1 + } + + return a.localeCompare(b) + }) .map(key => new GroupItem(key)) this._onDidChangeTreeData.fire() diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..7bc4cd8 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,3 @@ +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +}