diff --git a/src/hub/hub-client.ts b/src/hub/hub-client.ts index 3f161813..6116a9e4 100644 --- a/src/hub/hub-client.ts +++ b/src/hub/hub-client.ts @@ -58,3 +58,21 @@ export async function getTaskByVersion(taskId: number): Promise { + const restApi = new hubApi.ResourceApi(); + const result = await restApi.resourceByCatalogKindNameVersion(catalog, 'task', name, version); + return result.data.data; +} + +export async function getTaskById(taskId: number): Promise { + const restApi = new hubApi.ResourceApi(); + const result = await restApi.resourceById(taskId); + return result.data.data; +} + +export async function getTopRatedTasks(limit: number): Promise { + const restApi = new hubApi.ResourceApi(); + const result = await restApi.resourceList(limit); + return result.data.data; +} diff --git a/src/hub/hub-common.ts b/src/hub/hub-common.ts new file mode 100644 index 00000000..4a3e3a24 --- /dev/null +++ b/src/hub/hub-common.ts @@ -0,0 +1,31 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { ResourceData, ResourceVersionData } from '../tekton-hub-client/api'; + +export interface HubTaskInstallation { + url: string; + name: string; + tknVersion?: string; + minPipelinesVersion?: string; + asClusterTask: boolean; + taskVersion?: ResourceVersionData; +} + +export interface HubTaskUninstall { + name: string; + clusterTask: boolean; +} + +export interface InstalledTask extends ResourceData { + installedVersion?: ResourceVersionData; + clusterTask?: boolean; +} + +export type HubTask = InstalledTask | ResourceData + +export function isInstalledTask(task: HubTask): task is InstalledTask { + return (task as InstalledTask).installedVersion !== undefined; +} diff --git a/src/hub/hub-view.ts b/src/hub/hub-view.ts index 7febf714..f0534923 100644 --- a/src/hub/hub-view.ts +++ b/src/hub/hub-view.ts @@ -5,22 +5,39 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Disposable } from '../util/disposable'; -import { getTektonHubStatus, searchTask, TektonHubStatusEnum } from './hub-client'; +import { getTaskById, getTaskByNameAndVersion, getTektonHubStatus, getTopRatedTasks, searchTask, TektonHubStatusEnum } from './hub-client'; import { ResourceData } from '../tekton-hub-client'; import { taskPageManager } from './task-page-manager'; -import { installTask } from './install-task'; +import { installTask, installEvent } from './install-task'; import { version } from '../util/tknversion'; +import { getRawTasks } from '../yaml-support/tkn-tasks-provider'; +import { InstalledTask, isInstalledTask } from './hub-common'; +import { uninstallTaskEvent } from './uninstall-task'; export class TektonHubTasksViewProvider extends Disposable implements vscode.WebviewViewProvider { private webviewView: vscode.WebviewView; private tknVersion: string | undefined; + private installedTasks: InstalledTask[] | undefined; constructor( private readonly extensionUri: vscode.Uri, ) { super(); + + installEvent(async () => { + if (this.webviewView?.visible) { + await this.loadInstalledTasks(); + await this.loadRecommendedTasks(); + } + }); + + uninstallTaskEvent(() => { + if (this.webviewView?.visible) { + this.loadInstalledTasks(); + } + }); } resolveWebviewView(webviewView: vscode.WebviewView): void | Thenable { @@ -42,7 +59,7 @@ export class TektonHubTasksViewProvider extends Disposable implements vscode.Web this.register(webviewView.webview.onDidReceiveMessage(e => { switch (e.type) { case 'ready': - this.doCheckHub(); + this.handleReady(); break; case 'search': this.doSearch(e.data); @@ -70,8 +87,7 @@ export class TektonHubTasksViewProvider extends Disposable implements vscode.Web -
-
+
@@ -84,15 +100,25 @@ export class TektonHubTasksViewProvider extends Disposable implements vscode.Web this.webviewView?.webview?.postMessage(message); } - private async doCheckHub(): Promise { + private async handleReady(): Promise { + const hubAvailable = await this.doCheckHub(); + if (hubAvailable){ + await this.loadInstalledTasks(); + await this.loadRecommendedTasks(); + } + } + + private async doCheckHub(): Promise { const status = await getTektonHubStatus(); const tknVersions = await version(); this.tknVersion = tknVersions.pipeline; if (status.status !== TektonHubStatusEnum.Ok){ this.sendMessage({type: 'error', data: status.error}); + return false; } else { - this.sendMessage({type: 'tknVersion', data: tknVersions.pipeline}) + this.sendMessage({type: 'tknVersion', data: tknVersions.pipeline}); + return true; } } @@ -106,8 +132,65 @@ export class TektonHubTasksViewProvider extends Disposable implements vscode.Web } - private openTaskPage(task: ResourceData): void { + private async loadInstalledTasks(): Promise { + const rawTasks = await getRawTasks(true); + const installedTasksRaw = rawTasks.filter( task => { + if (!task.metadata?.labels){ + return false; + } + return task.metadata?.labels['hub.tekton.dev/catalog'] !== undefined; + }); + + const installedTasks: InstalledTask[] = []; + for (const installedTask of installedTasksRaw) { + try { + const installedVersion = await getTaskByNameAndVersion(installedTask.metadata.labels['hub.tekton.dev/catalog'], installedTask.metadata.name, installedTask.metadata.labels['app.kubernetes.io/version']); + const task: InstalledTask = installedVersion.resource; + task.installedVersion = installedVersion; + const tmpTask = Object.assign({}, task); + tmpTask.installedVersion.resource = undefined; + tmpTask.clusterTask = installedTask.kind === 'ClusterTask'; + installedTasks.push(tmpTask); + } catch (err) { + // ignore errors + } + } + this.installedTasks = installedTasks; + this.sendMessage({type: 'installedTasks', data: installedTasks}); + return; + } + + private async openTaskPage(task: ResourceData | InstalledTask): Promise { + if (isInstalledTask(task)) { + const taskData: InstalledTask = await getTaskById(task.id); + taskData.installedVersion = task.installedVersion; + task = taskData; + } taskPageManager.showTaskPageView(task, this.tknVersion); } + private async loadRecommendedTasks(): Promise { + try { + const recommendedTasks = await getTopRatedTasks(7); + let result = []; + if (this.installedTasks) { + const installedId = this.installedTasks.map((it) => it.id); + + for (const task of recommendedTasks) { + if (installedId.indexOf(task.id) === -1) { + result.push(task); + } + } + } else { + result = recommendedTasks; + } + + this.sendMessage({type: 'recommendedTasks', data: result}); + } catch (err){ + console.error(err); + } + + } + } + diff --git a/src/hub/install-common.ts b/src/hub/install-common.ts deleted file mode 100644 index 38199981..00000000 --- a/src/hub/install-common.ts +++ /dev/null @@ -1,13 +0,0 @@ -/*----------------------------------------------------------------------------------------------- - * Copyright (c) Red Hat, Inc. All rights reserved. - * Licensed under the MIT License. See LICENSE file in the project root for license information. - *-----------------------------------------------------------------------------------------------*/ - -export interface HubTaskInstallation { - url: string; - name: string; - tknVersion?: string; - minPipelinesVersion?: string; - asClusterTask: boolean; - taskVersion?: string; -} diff --git a/src/hub/install-task.ts b/src/hub/install-task.ts index 3088c937..9289d54b 100644 --- a/src/hub/install-task.ts +++ b/src/hub/install-task.ts @@ -5,43 +5,47 @@ import { Command, getStderrString, tkn } from '../tkn'; import * as vscode from 'vscode'; -import { HubTaskInstallation } from './install-common'; +import { HubTaskInstallation } from './hub-common'; import * as semver from 'semver'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs-extra'; import { DownloadUtil } from '../util/download'; -export async function installTask(task: HubTaskInstallation): Promise { +const installEventEmitter = new vscode.EventEmitter() +export const installEvent = installEventEmitter.event; + +export async function installTask(task: HubTaskInstallation): Promise { try { if (task.tknVersion && task.minPipelinesVersion){ if (semver.lt(task.tknVersion, task.minPipelinesVersion)){ const result = await vscode.window.showWarningMessage(`This task requires Tekton Pipelines >= ${task.minPipelinesVersion} and is incompatible with the version of Tekton Pipelines installed on your cluster. Install anyway?`, 'Install', 'Cancel') if (result === 'Install'){ - await doInstall(task); + return doInstall(task); } else { - return; + return false; } } } - await doInstall(task); + return doInstall(task); } catch (err) { vscode.window.showErrorMessage(err.toString()); + return false; } } -async function doInstall(task: HubTaskInstallation): Promise { +async function doInstall(task: HubTaskInstallation): Promise { if (task.asClusterTask) { - await doInstallClusterTask(task); + return doInstallClusterTask(task); } else { - doInstallTask(task); + return doInstallTask(task); } } -function doInstallTask(task: HubTaskInstallation): void { - vscode.window.withProgress({title: `Installing ${task.name}`,location: vscode.ProgressLocation.Notification}, async () => { +async function doInstallTask(task: HubTaskInstallation): Promise { + return await vscode.window.withProgress({title: `Installing ${task.name}`,location: vscode.ProgressLocation.Notification}, async () => { try { const tasks = await tkn.getRawTasks(); if (tasks) { @@ -49,25 +53,28 @@ function doInstallTask(task: HubTaskInstallation): void { if (rawTask.metadata.name === task.name) { const overwriteResult = await vscode.window.showWarningMessage(`You already has Task '${task.name}'. Do you want to overwrite it?`, 'Overwrite', 'Cancel'); if (overwriteResult !== 'Overwrite') { - return; + return false; } } } } - const result = await tkn.execute(Command.hubInstall(task.name, task.taskVersion)); + const result = await tkn.execute(Command.hubInstall(task.name, task.taskVersion.version)); if (result.error){ vscode.window.showWarningMessage(`Task installation failed: ${getStderrString(result.error)}`); } else { vscode.window.showInformationMessage(`Task ${task.name} installed.`); + installEventEmitter.fire(task); + return true; } } catch (err) { vscode.window.showErrorMessage(err.toString()); } + return false; }); } const taskRegexp = /^kind:[\t ]*Task[\t ]*$/m; -async function doInstallClusterTask(task: HubTaskInstallation): Promise { - await vscode.window.withProgress({title: `Installing ${task.name}`,location: vscode.ProgressLocation.Notification}, async () => { +async function doInstallClusterTask(task: HubTaskInstallation): Promise { + return await vscode.window.withProgress({title: `Installing ${task.name}`,location: vscode.ProgressLocation.Notification}, async () => { try { const tasks = await tkn.getRawClusterTasks(); if (tasks) { @@ -75,7 +82,7 @@ async function doInstallClusterTask(task: HubTaskInstallation): Promise { if (rawTask.metadata.name === task.name) { const overwriteResult = await vscode.window.showWarningMessage(`You already has ClusterTask '${task.name}'. Do you want to overwrite it?`, 'Overwrite', 'Cancel'); if (overwriteResult !== 'Overwrite') { - return; + return false; } } } @@ -95,9 +102,12 @@ async function doInstallClusterTask(task: HubTaskInstallation): Promise { vscode.window.showWarningMessage(`ClusterTask installation failed: ${getStderrString(result.error)}`); } else { vscode.window.showInformationMessage(`ClusterTask ${task.name} installed.`); + installEventEmitter.fire(task); + return true; } } catch (err) { vscode.window.showErrorMessage(err.toString()); } + return false; }); } diff --git a/src/hub/task-page-manager.ts b/src/hub/task-page-manager.ts index a58ad755..821ca0c5 100644 --- a/src/hub/task-page-manager.ts +++ b/src/hub/task-page-manager.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ import { ViewColumn } from 'vscode'; -import { ResourceData } from '../tekton-hub-client'; import { Disposable } from '../util/disposable'; +import { HubTask } from './hub-common'; import { TaskPageView } from './task-page-view'; @@ -18,7 +18,7 @@ export class TaskPageManager extends Disposable { super(); } - showTaskPageView(task: ResourceData, tknVersion: string): void { + showTaskPageView(task: HubTask, tknVersion: string): void { if (!this.activePreview){ this.createTaskPageView(task, tknVersion); @@ -27,7 +27,7 @@ export class TaskPageManager extends Disposable { } } - createTaskPageView(task: ResourceData, tknVersion: string): TaskPageView { + createTaskPageView(task: HubTask, tknVersion: string): TaskPageView { const preview = TaskPageView.create(task, tknVersion, ViewColumn.One); this.activePreview = preview; diff --git a/src/hub/task-page-view.ts b/src/hub/task-page-view.ts index 245538af..3b302d69 100644 --- a/src/hub/task-page-view.ts +++ b/src/hub/task-page-view.ts @@ -11,13 +11,15 @@ import { debounce } from 'debounce'; import { ResourceData } from '../tekton-hub-client'; import { getTaskByVersion, getVersions } from './hub-client'; import { installTask } from './install-task'; +import { uninstallTask } from './uninstall-task'; +import { HubTask, HubTaskInstallation, HubTaskUninstall, InstalledTask, isInstalledTask } from './hub-common'; export class TaskPageView extends Disposable { static viewType = 'tekton.pipeline.start.wizard'; static title: string; - public static create(task: ResourceData, tknVersion: string, previewColumn: vscode.ViewColumn): TaskPageView { + public static create(task: HubTask, tknVersion: string, previewColumn: vscode.ViewColumn): TaskPageView { TaskPageView.title = getTitle(task); const webview = vscode.window.createWebviewPanel( 'task-view', @@ -38,7 +40,7 @@ export class TaskPageView extends Disposable { private readonly onDidChangeViewStateEmitter = new vscode.EventEmitter(); public readonly onDidChangeViewState = this.onDidChangeViewStateEmitter.event; - constructor(webview: vscode.WebviewPanel, private task: ResourceData, private tknVersion: string) { + constructor(webview: vscode.WebviewPanel, private task: HubTask, private tknVersion: string) { super(); this.webviewPanel = webview; this.register(this.webviewPanel.onDidDispose(() => { @@ -55,11 +57,16 @@ export class TaskPageView extends Disposable { this.getTaskVersions(e.data); break; case 'installTask': - installTask(e.data); + this.installTask(e.data); break; case 'getTask': this.getTask(e.data); break; + case 'uninstallTask': + this.uninstallTask(e.data); + break; + default: + console.error(`Unknown message received - type: "${e.type}" data: ${JSON.stringify(e.data)}`); } })); @@ -88,6 +95,24 @@ export class TaskPageView extends Disposable { this.sendTask(); } + private async installTask(task: HubTaskInstallation): Promise{ + const isInstalled = await installTask(task); + if (isInstalled) { + (this.task as InstalledTask).installedVersion = task.taskVersion; + (this.task as InstalledTask).clusterTask = task.asClusterTask; + this.sendTask(); + } + } + + private async uninstallTask(task: HubTaskUninstall): Promise{ + await uninstallTask(task); + if (isInstalledTask(this.task)){ + this.task.installedVersion = undefined; + this.task.clusterTask = undefined; + this.sendTask(); + } + } + private async getTaskVersions(id: number): Promise{ const versions = await getVersions(id); this.postMessage({type: 'setVersions', data: versions}); @@ -172,6 +197,6 @@ function getLocalResourceRoots(): vscode.Uri[] { } function getTitle(task: ResourceData): string { - const name = task.latestVersion.displayName ? task.latestVersion.displayName : task.name; + const name = task.latestVersion?.displayName ? task.latestVersion.displayName : task.name; return `Task: ${name}`; } diff --git a/src/hub/uninstall-task.ts b/src/hub/uninstall-task.ts new file mode 100644 index 00000000..60c2aa51 --- /dev/null +++ b/src/hub/uninstall-task.ts @@ -0,0 +1,27 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { Command, getStderrString, tkn } from '../tkn'; +import { HubTaskUninstall } from './hub-common'; +import * as vscode from 'vscode'; + +const uninstallTaskEmitter = new vscode.EventEmitter(); +export const uninstallTaskEvent = uninstallTaskEmitter.event; + +export async function uninstallTask(task: HubTaskUninstall): Promise { + let command; + if (task.clusterTask){ + command = Command.deleteClusterTask(task.name); + } else { + command = Command.deleteTask(task.name); + } + const result = await tkn.execute(command); + if (result.error){ + vscode.window.showWarningMessage(`Failed to uninstall: : ${getStderrString(result.error)}`); + } else { + vscode.window.showInformationMessage(`Task ${task.name} uninstalled.`); + uninstallTaskEmitter.fire(task); + } +} diff --git a/src/tekton.d.ts b/src/tekton.d.ts index 8c891032..7861ed55 100644 --- a/src/tekton.d.ts +++ b/src/tekton.d.ts @@ -49,6 +49,7 @@ export interface TknMetadata { namespace?: string; uid?: string; resourceVersion?: string; + labels?: {[key: string]: string}; } export interface TknParams { diff --git a/src/webview/common/dom-util.ts b/src/webview/common/dom-util.ts index c5d7b2a3..b2587a00 100644 --- a/src/webview/common/dom-util.ts +++ b/src/webview/common/dom-util.ts @@ -3,11 +3,10 @@ * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ -export function createDiv(className: string, id?: string): HTMLDivElement { +export function createDiv(...className: string[]): HTMLDivElement { const element = document.createElement('div'); - if (className) element.classList.add(className); - if (id){ - element.id = id; + if (className){ + element.classList.add(...className); } return element; } diff --git a/src/webview/common/list-widget.ts b/src/webview/common/list-widget.ts index 6d085620..08e75dd8 100644 --- a/src/webview/common/list-widget.ts +++ b/src/webview/common/list-widget.ts @@ -4,25 +4,30 @@ *-----------------------------------------------------------------------------------------------*/ import './list.css'; import { createDiv } from './dom-util'; -import { BaseWidget, Widget } from './widget'; +import { BaseWidget, Listener, Widget } from './widget'; + +interface SizeAndPosition { + top: number; + width: number; +} export abstract class ListWidget extends BaseWidget { + protected itemListChangedListener: Listener | undefined; + constructor(element: HTMLElement) { super(); this.element = element; } - clear(): void { - if (this.element){ - this.element.innerHTML = ''; - } - } - show(items: T[]): void { this.clear(); for (const item of items) { this.element.appendChild(new ItemContainer(this.createItemWidget(item)).getElement()); } + + if (this.itemListChangedListener) { + this.itemListChangedListener(items); + } } showPlaceholder(label: string): void { @@ -31,10 +36,192 @@ export abstract class ListWidget extends BaseWidget { message.textContent = label; this.element.appendChild(message); } + + addItemListChangedListener(listener: Listener): void { + this.itemListChangedListener = listener; + } abstract createItemWidget(item: T): Widget; } +export interface CollapsibleListState { + [key: string]: boolean; +} + +export class CollapsibleList extends BaseWidget { + + private items = new Map(); + + constructor(element: HTMLElement, private stateListener?: Listener, private state?: CollapsibleListState) { + super(); + this.element = element; + } + + addSubList(title: string, subList: ListWidget): void { + const collapsible = new Collapsible(title, subList.getElement().childNodes.length, subList); + if (this.state){ + const collapsed = this.state[title] ?? false; + if (collapsed) { + collapsible.expand(); + } else { + collapsible.collapse(); + } + } + const splitView = new SplitView(); + splitView.addWidget(collapsible); + this.element.appendChild(splitView.getElement()); + + this.items.set(splitView, collapsible); + collapsible.onCollapsedStateChanged(() => { + this.updateLayout(); + if (this.stateListener){ + this.state[title] = collapsible.isCollapsed; + this.stateListener(this.state); + } + }); + + subList.addItemListChangedListener((items) => { + collapsible.setBadgeText(items.length.toString()); + }); + + this.updateLayout(); + } + + updateLayout(): void { + setTimeout(()=> { + let top = 0; + let height = this.element.clientHeight; + let collapsedNum = 0; + for (const c of this.items.values()) { + if (c.isCollapsed) { + collapsedNum ++; + } + } + let notCollapsedHeight = 0; + if (collapsedNum !== this.items.size){ + notCollapsedHeight = (height - (collapsedNum * 22)) / (this.items.size - collapsedNum) ; + } + + height = 22; + for (const [w, c] of this.items.entries()) { + w.getElement().style.top = `${top}px`; + if (c.isCollapsed) { + top += 22; + w.getElement().style.height = `${height}px`; + } else { + top += notCollapsedHeight; + w.getElement().style.height = `${notCollapsedHeight}px`; + } + } + + }, 30); + } +} + +class SplitView extends BaseWidget { + constructor() { + super(); + this.element = createDiv('split-view'); + } + + addWidget(widget: Widget): void { + this.element.appendChild(widget.getElement()); + } + +} +class Collapsible extends BaseWidget { + private header: HTMLElement; + private body: HTMLElement; + private twisty: HTMLElement; + private badgeElement: HTMLElement; + + isCollapsed = false; + + private collapsedStateListener: Listener; + + constructor(title: string, badge: number | undefined, child: Widget) { + super(); + this.element = createDiv('collapsible'); + this.header = createDiv('collapsible-header'); + this.body = createDiv('collapsible-body'); + + this.createHeader(title, badge); + this.createBody(child); + } + + onCollapsedStateChanged(listener: Listener): void { + this.collapsedStateListener = listener; + } + + setBadgeText(text: string): void { + this.badgeElement.textContent = text; + } + + private createHeader(title: string, badge?: number): void { + this.twisty = createDiv('codicon', 'codicon-view-pane-container-expanded'); + this.header.appendChild(this.twisty); + + const titleElement = document.createElement('h3'); + titleElement.className = 'title'; + titleElement.textContent = title; + this.header.appendChild(titleElement); + const badgeWrapper = createDiv('count-badge-wrapper'); + + this.badgeElement = createDiv('monaco-count-badge'); + badgeWrapper.appendChild(this.badgeElement); + this.badgeElement.textContent = badge.toString(); + + this.header.appendChild(badgeWrapper); + + this.element.appendChild(this.header); + this.header.onclick = () => { + this.handleClick(); + }; + + this.header.tabIndex = 0; + this.header.onfocus = () => { + this.header.classList.add('focused'); + }; + + this.header.onblur = () => { + this.header.classList.remove('focused'); + } + } + + private createBody(child: Widget): void { + this.body.appendChild(child.getElement()); + this.element.appendChild(this.body); + } + + private handleClick(): void { + if (this.isCollapsed){ + this.collapse() + } else { + this.expand(); + } + + if (this.collapsedStateListener){ + this.collapsedStateListener(this.isCollapsed); + } + } + + collapse(): void { + this.element.classList.add('animated'); + this.twisty.classList.remove('codicon-view-pane-container-collapsed'); + this.twisty.classList.add('codicon-view-pane-container-expanded'); + this.body.style.display = 'block'; + this.isCollapsed = false; + } + + expand(): void { + this.element.classList.add('animated'); + this.twisty.classList.remove('codicon-view-pane-container-expanded'); + this.twisty.classList.add('codicon-view-pane-container-collapsed'); + this.body.style.display = 'none'; + this.isCollapsed = true; + } +} + class ItemContainer extends BaseWidget { constructor(item: Widget) { diff --git a/src/webview/common/list.css b/src/webview/common/list.css index cbb89226..8c4fbf86 100644 --- a/src/webview/common/list.css +++ b/src/webview/common/list.css @@ -12,3 +12,98 @@ cursor: default; display: flex; } + +.split-view { + white-space: normal; + position: absolute; + width: 100%; +} + +.collapsible { + overflow: hidden; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding-left: 16px; +} + +.collapsible.animated { + transition-duration: 0.15s; + transition-timing-function: ease-out; + transition-property: height; +} +.split-view:first-of-type>.collapsible>.collapsible-header { + border-top: none!important; +} +.collapsible-header { + border-top: none; + position: relative; + height: 22px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + overflow: hidden; + display: flex; + cursor: pointer; + align-items: center; + box-sizing: border-box; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border); + line-height: 22px; + user-select: none; +} + +.focused { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + opacity: 1!important; + outline-color: var(--vscode-focusBorder); +} + +.collapsible-body { + overflow-y: auto; + flex: 1; +} +.codicon-view-pane-container-collapsed .codicon-view-pane-container-expanded{ + font-size: 16px; +} +.codicon-view-pane-container-expanded:before { + content: '\eab4'; +} + +.codicon-view-pane-container-collapsed:before { + content: '\eab6'; +} + +h3.title { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font-size: 11px; + min-width: 3ch; + -webkit-margin-before: 0; + -webkit-margin-after: 0; + font-weight: bold; +} + +.count-badge-wrapper{ + margin-right: 4px; + display: initial; + margin-left: auto; +} + +.monaco-count-badge { + padding: 3px 6px; + border-radius: 11px; + font-size: 11px; + min-width: 18px; + min-height: 18px; + line-height: 11px; + font-weight: 400; + text-align: center; + display: inline-block; + box-sizing: border-box; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} diff --git a/src/webview/common/loader.css b/src/webview/common/loader.css new file mode 100644 index 00000000..6bcd7783 --- /dev/null +++ b/src/webview/common/loader.css @@ -0,0 +1,24 @@ +.progress { + justify-content: flex-start; + align-items: center; + position: relative; + padding: 0 5px; + display: flex; + height: 2px; + width: 100%; + display: none; +} + +.progress-value { + background: var(--vscode-progressBar-background); + transition: width 100ms linear; + animation-name: progress; + animation-duration: 4s; + animation-iteration-count: infinite; + animation-timing-function: linear; + transform: translate3d(0px, 0px, 0px); + height: 2px; + width: 2%; +} + +@keyframes progress { from { transform: translateX(0%) scaleX(1) } 50% { transform: translateX(2500%) scaleX(3) } to { transform: translateX(4900%) scaleX(1) } } diff --git a/src/webview/common/loader.ts b/src/webview/common/loader.ts new file mode 100644 index 00000000..ac4ad1a5 --- /dev/null +++ b/src/webview/common/loader.ts @@ -0,0 +1,26 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { createDiv } from './dom-util'; +import { BaseWidget } from './widget'; +import './loader.css'; + +export class Loader extends BaseWidget { + constructor() { + super(); + this.element = createDiv('progress'); + const value = createDiv('progress-value'); + this.element.appendChild(value); + this.hide(); + } + + hide(): void { + this.element.style.display = 'none'; + } + + show(): void { + this.element.style.display = 'block'; + } +} diff --git a/src/webview/common/widget.ts b/src/webview/common/widget.ts index b667a86a..86eed462 100644 --- a/src/webview/common/widget.ts +++ b/src/webview/common/widget.ts @@ -16,4 +16,10 @@ export class BaseWidget implements Widget { getElement(): HTMLElement { return this.element; } + + clear(): void { + if (this.element){ + this.element.innerHTML = ''; + } + } } diff --git a/src/webview/hub/hub.css b/src/webview/hub/hub.css index f3cc4f60..fdabdcf0 100644 --- a/src/webview/hub/hub.css +++ b/src/webview/hub/hub.css @@ -9,8 +9,10 @@ html, body { background-color: var(--vscode-editor-background); } -#tasksList { - +.collapsibleListContainer{ + width: 100%; + height: 100%; + position: relative; } .listContainer { @@ -197,4 +199,3 @@ html, body { .list-action-bar .action-label:hover { background-color: var(--vscode-extensionButton-prominentHoverBackground); } - diff --git a/src/webview/hub/index.ts b/src/webview/hub/index.ts index 859f67e3..941e04b5 100644 --- a/src/webview/hub/index.ts +++ b/src/webview/hub/index.ts @@ -27,14 +27,16 @@ window.addEventListener('message', event => { case 'tknVersion': view.setTknVersion(event.data.data); break; + case 'installedTasks': + view.setInstalledTasks(event.data.data); + break; + case 'recommendedTasks': + view.setRecommendedTasks(event.data.data); + break; + default: + console.error(`Cannot handle: ${JSON.stringify(event.data)}`); + } }, false); -// Check if we have an old state to restore from -const previousState = vscode.getState(); -if (previousState) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - view.restore(previousState as any); -} - vscode.postMessage({type: 'ready'}); diff --git a/src/webview/hub/task-view.ts b/src/webview/hub/task-view.ts index 7c72de83..986eb859 100644 --- a/src/webview/hub/task-view.ts +++ b/src/webview/hub/task-view.ts @@ -9,10 +9,11 @@ import { VSMessage, ViewState} from '../common/vscode-api'; import { debounce } from 'debounce'; import {ResourceData} from '../../tekton-hub-client/api'; import { BaseWidget, Listener, Widget } from '../common/widget'; -import { ListWidget } from '../common/list-widget'; +import { CollapsibleList, CollapsibleListState, ListWidget } from '../common/list-widget'; import { createDiv, createSpan } from '../common/dom-util'; import * as semver from 'semver'; -import { HubTaskInstallation } from '../../hub/install-common'; +import { HubTask, HubTaskInstallation, InstalledTask } from '../../hub/hub-common'; +import { Loader } from '../common/loader'; export class SearchInput { @@ -49,7 +50,7 @@ export class SearchInput { export class TaskItem extends BaseWidget { - constructor(private task: ResourceData, private messageSender: VSMessage, private tknVersion?: string) { + constructor(private task: HubTask, private messageSender: VSMessage, private loader: Loader, private tknVersion?: string) { super(); this.element = createDiv('task-list-item'); const iconContainer = createDiv('icon-container'); @@ -60,7 +61,11 @@ export class TaskItem extends BaseWidget { iconContainer.appendChild(image); this.createDetails(); this.element.onclick = () => { - this.messageSender.postMessage({type: 'openTaskPage', data: this.task}); + try { + this.messageSender.postMessage({type: 'openTaskPage', data: this.task}); + } catch (err) { + console.error(err); + } } } @@ -81,11 +86,11 @@ export class TaskItem extends BaseWidget { const header = createDiv('header'); const name = createSpan('name'); - name.innerText = this.task.latestVersion.displayName ? this.task.latestVersion.displayName : this.task.name; + name.innerText = this.task.latestVersion?.displayName ? this.task.latestVersion.displayName : this.task.name; header.appendChild(name); const version = createSpan('version'); - version.innerText = this.task.latestVersion.version; + version.innerText = this.task.latestVersion ? this.task.latestVersion.version : (this.task as InstalledTask).installedVersion.version; header.appendChild(version); const ratings = createSpan('ratings'); @@ -102,7 +107,7 @@ export class TaskItem extends BaseWidget { private createDescription(details: HTMLDivElement): void { const description = createDiv('description'); description.classList.add('ellipsis'); - description.textContent = this.task.latestVersion.description; + description.textContent = this.task.latestVersion ? this.task.latestVersion.description : (this.task as InstalledTask).installedVersion.description; details.appendChild(description); } @@ -123,16 +128,19 @@ export class TaskItem extends BaseWidget { const installEl = document.createElement('li'); installEl.classList.add('action-item'); - const installButton = document.createElement('a'); - installButton.classList.add('action-label', 'codicon', 'extension-action', 'install'); - - installButton.textContent = 'Install'; - installButton.onclick = (e) =>{ - e.preventDefault(); - e.stopPropagation(); - this.sendInstall(); - }; - installEl.appendChild(installButton); + if (!(this.task as InstalledTask).installedVersion) { + const installButton = document.createElement('a'); + installButton.classList.add('action-label', 'codicon', 'extension-action', 'install'); + + installButton.textContent = 'Install'; + installButton.onclick = (e) =>{ + e.preventDefault(); + e.stopPropagation(); + this.sendInstall(); + }; + installEl.appendChild(installButton); + } + actionContainer.appendChild(installEl); actionBar.appendChild(actionContainer); footer.appendChild(actionBar); @@ -140,17 +148,18 @@ export class TaskItem extends BaseWidget { } private sendInstall(): void { + this.loader.show(); this.messageSender.postMessage({type: 'installTask', data: { url: this.task.latestVersion.rawURL, name: this.task.name, minPipelinesVersion: this.task.latestVersion.minPipelinesVersion, tknVersion: this.tknVersion, - taskVersion: this.task.latestVersion.version + taskVersion: this.task.latestVersion } as HubTaskInstallation}); } private addVersionCheck(container: HTMLUListElement): void { - if (this.task.latestVersion.minPipelinesVersion) { + if (this.task.latestVersion && this.task.latestVersion.minPipelinesVersion) { if (semver.lt(this.tknVersion, this.task.latestVersion.minPipelinesVersion)){ const versionWarning = document.createElement('li'); versionWarning.classList.add('action-item'); @@ -166,11 +175,11 @@ export class TaskItem extends BaseWidget { } -export class TaskList extends ListWidget { +export class TaskList extends ListWidget { tknVersion: string | undefined; - constructor(element: HTMLElement, private messageSender: VSMessage){ + constructor(element: HTMLElement, private messageSender: VSMessage, private loader: Loader){ super(element); } @@ -178,13 +187,16 @@ export class TaskList extends ListWidget { this.element.innerText = message; } - createItemWidget(item: ResourceData): Widget { - return new TaskItem(item, this.messageSender, this.tknVersion); + createItemWidget(item: HubTask): Widget { + return new TaskItem(item, this.messageSender, this.loader, this.tknVersion); } - show(items: ResourceData[]): void { + show(items: HubTask[]): void { if (items.length === 0) { this.showPlaceholder('No tasks found.'); + if (this.itemListChangedListener) { + this.itemListChangedListener(items); + } } else { super.show(items); } @@ -196,19 +208,51 @@ export class TaskView { private searchInput: SearchInput; private taskList: TaskList; + private installedTasks: Map; + private welcomeList: CollapsibleList; + private mainContainer: HTMLElement; + private loader: Loader; + private installedList: TaskList | undefined; + private recommendedList: TaskList | undefined; + private state: TaskViewState; constructor(private vscodeAPI: VSMessage & ViewState) { this.searchInput = new SearchInput(document.getElementById('taskInput') as HTMLInputElement, vscodeAPI); this.searchInput.onInputChange((input) => { if (input) { + this.loader.show(); this.vscodeAPI.postMessage({type: 'search', data: input}); } else { - this.taskList.clear(); - this.vscodeAPI.setState({input: '', tasks: [], tknVersion: this.taskList.tknVersion}); + this.showWelcomeList(); } - }) + }); + + // Check if we have an old state to restore from + this.state = vscodeAPI.getState() as TaskViewState; + if (!this.state || !this.state.welcomeList){ + this.state = {input: undefined, welcomeList: {}}; + } + + this.loader = new Loader(); + const rootElement = document.getElementById('root') + rootElement.insertBefore(this.loader.getElement(), rootElement.firstChild); + this.loader.show(); - this.taskList = new TaskList(document.getElementById('tasksList'), this.vscodeAPI); + // + const taskListContainer = createDiv(); + taskListContainer.id = 'tasksList'; + this.taskList = new TaskList(taskListContainer, this.vscodeAPI, this.loader); + + this.mainContainer = document.getElementById('mainContainer'); + const listContainer = createDiv('collapsibleListContainer'); + this.mainContainer.appendChild(listContainer); + this.welcomeList = new CollapsibleList(listContainer, (state) => { + this.state.welcomeList = state; + this.vscodeAPI.setState(this.state); + }, this.state.welcomeList); + document.body.onresize = () => { + this.welcomeList.updateLayout(); + } } setErrorState(message: string): void { @@ -217,27 +261,57 @@ export class TaskView { } showTasks(tasks: ResourceData[]): void { - this.taskList.show(tasks); - this.vscodeAPI.setState({input: this.searchInput.value, tasks: tasks, tknVersion: this.taskList.tknVersion}); + this.loader.hide(); + if (this.searchInput.value){ + if (this.installedTasks){ + for (const task of tasks){ + if (this.installedTasks.has(task.name)){ + (task as InstalledTask).installedVersion = this.installedTasks.get(task.name).installedVersion; + (task as InstalledTask).clusterTask = this.installedTasks.get(task.name).clusterTask; + } + } + } + this.taskList.show(tasks); + this.mainContainer.removeChild(this.welcomeList.getElement()); + this.mainContainer.appendChild(this.taskList.getElement()); + } else { + this.showWelcomeList(); + } } - restore(state: TaskViewState): void { - this.taskList.tknVersion = state.tknVersion; - if (state.tasks.length !== 0) { - this.taskList.show(state.tasks); - } - this.searchInput.setValue(state.input); + private showWelcomeList(): void { + this.mainContainer.removeChild(this.taskList.getElement()); + this.mainContainer.appendChild(this.welcomeList.getElement()); } setTknVersion(version: string): void { this.taskList.tknVersion = version; } + setInstalledTasks(tasks: InstalledTask[]): void { + this.loader.hide(); + this.installedTasks = new Map(tasks.map(it => [it.name, it])); + if (!this.installedList){ + const installedElement = createDiv(); + this.installedList = new TaskList(installedElement, this.vscodeAPI, this.loader); + this.welcomeList.addSubList('INSTALLED', this.installedList); + } + this.installedList.show(tasks); + } + + setRecommendedTasks(tasks: ResourceData[]): void { + if (!this.recommendedList){ + const recommendedElement = createDiv(); + this.recommendedList = new TaskList(recommendedElement, this.vscodeAPI, this.loader); + this.welcomeList.addSubList('RECOMMENDED', this.recommendedList); + } + this.recommendedList.show(tasks); + } + } interface TaskViewState { input: string; - tknVersion: string; - tasks: ResourceData[]; + welcomeList: CollapsibleListState; } diff --git a/src/webview/task-page/task-widget.ts b/src/webview/task-page/task-widget.ts index 3005b649..f73c9407 100644 --- a/src/webview/task-page/task-widget.ts +++ b/src/webview/task-page/task-widget.ts @@ -8,7 +8,7 @@ import { createDiv, createSpan } from '../common/dom-util'; import { BaseWidget } from '../common/widget'; import { VSMessage } from '../common/vscode-api'; import * as hljs from 'highlight.js/lib/core'; -import { HubTaskInstallation } from '../../hub/install-common'; +import { HubTaskInstallation, HubTaskUninstall, InstalledTask, isInstalledTask } from '../../hub/hub-common'; import * as yaml from 'highlight.js/lib/languages/yaml'; import {CodeLineNumbers} from 'code-line-numbers'; import * as semver from 'semver'; @@ -43,9 +43,13 @@ export class TaskWidget extends BaseWidget { this.md = new MarkdownIt(); } - showTask(task: ResourceData, tknVersion: string): void { + showTask(task: ResourceData | InstalledTask, tknVersion: string): void { this.task = task; - this.currentVersion = task.latestVersion; + if (isInstalledTask(task)){ + this.currentVersion = task.installedVersion; + } else { + this.currentVersion = task.latestVersion; + } this.versions = undefined; this.tknVersion = tknVersion; this.updatePage(); @@ -61,6 +65,7 @@ export class TaskWidget extends BaseWidget { for (const ver of this.versions.versions){ const opt = document.createElement('option'); opt.value = ver.version; + console.error(this.task); if (ver.version === this.task.latestVersion.version){ opt.text = ver.version + ' (latest)'; } else { @@ -166,6 +171,22 @@ export class TaskWidget extends BaseWidget { }; // install button + if (!isInstalledTask(this.task)){ + this.addInstallButton(actionsContainer); + } else { + this.addUninstallButton(actionsContainer); + } + + // TKN Version + if (this.tknVersion){ + this.addVersionCheck(actionsContainer); + } + + details.appendChild(actions); + + } + + private addInstallButton(actionsContainer: HTMLUListElement): void { const installLi = document.createElement('li'); installLi.classList.add('action-item', 'action-dropdown-item'); const installButton = document.createElement('a'); @@ -188,14 +209,19 @@ export class TaskWidget extends BaseWidget { tknDropdown.appendChild(dropdownLabel); installLi.appendChild(tknDropdown); actionsContainer.appendChild(installLi); + } - // TKN Version - if (this.tknVersion){ - this.addVersionCheck(actionsContainer); + private addUninstallButton(actionsContainer: HTMLUListElement): void { + const uninstallLi = document.createElement('li'); + uninstallLi.classList.add('action-item'); + const uninstallButton = document.createElement('a'); + uninstallButton.classList.add('action-label', 'codicon', 'extension-action', 'label', 'uninstall'); + uninstallButton.textContent = 'Uninstall'; + uninstallButton.onclick = () => { + this.sendUninstall(); } - - details.appendChild(actions); - + uninstallLi.appendChild(uninstallButton); + actionsContainer.appendChild(uninstallLi); } private sendInstall(asCluster = false): void { @@ -205,17 +231,26 @@ export class TaskWidget extends BaseWidget { minPipelinesVersion: this.currentVersion.minPipelinesVersion, tknVersion: this.tknVersion, asClusterTask: asCluster, - taskVersion: this.currentVersion.version + taskVersion: this.currentVersion } as HubTaskInstallation}); } + private sendUninstall(): void { + if (isInstalledTask(this.task)) { + this.messageSender.postMessage({type: 'uninstallTask', data: { + clusterTask: this.task.clusterTask, + name: this.task.name + } as HubTaskUninstall}); + } + } + private showInstallOptions(parent: HTMLAnchorElement): void { const rmenu = document.getElementById('rmenu'); rmenu.className = 'show'; rmenu.style.top = parent.getBoundingClientRect().bottom + 'px'; rmenu.style.left = parent.getBoundingClientRect().left + 'px'; rmenu.innerHTML = ''; - + const installCluster = document.createElement('a'); installCluster.text = 'Install as ClusterTask'; rmenu.appendChild(installCluster); diff --git a/src/yaml-support/tkn-tasks-provider.ts b/src/yaml-support/tkn-tasks-provider.ts index 0bbe0f13..19621fc7 100644 --- a/src/yaml-support/tkn-tasks-provider.ts +++ b/src/yaml-support/tkn-tasks-provider.ts @@ -55,9 +55,9 @@ interface Param { value: string | string[]; } -export async function getRawTasks(): Promise { +export async function getRawTasks(forceLoad?: boolean): Promise { let allRawTasks: TknTask[]; - if (cache.has(tasksKey)){ + if (cache.has(tasksKey) && !forceLoad){ allRawTasks = cache.get(tasksKey); } else { const [rawClusterTasks, rawTasks] = await Promise.all([tkn.getRawClusterTasks(), tkn.getRawTasks()]); diff --git a/test/hub/install-task.test.ts b/test/hub/install-task.test.ts index 08f211cc..0756cb6c 100644 --- a/test/hub/install-task.test.ts +++ b/test/hub/install-task.test.ts @@ -13,6 +13,7 @@ import { DownloadUtil } from '../../src/util/download'; import * as os from 'os'; import * as fs from 'fs-extra'; import * as path from 'path'; +import { ResourceVersionData } from '../../src/tekton-hub-client'; const expect = chai.expect; chai.use(sinonChai); @@ -48,7 +49,7 @@ suite('Install Task', () => { name: 'foo', url: 'https://some/path/task.yaml', asClusterTask: false, - taskVersion: '1.0' + taskVersion: {version: '1.0'} as ResourceVersionData }); expect(executeStub).calledOnceWith(Command.hubInstall('foo', '1.0')); diff --git a/test/yaml-support/tkn-yaml-scheme-generator.test.ts b/test/yaml-support/tkn-yaml-scheme-generator.test.ts index eddd1785..2e261b9e 100644 --- a/test/yaml-support/tkn-yaml-scheme-generator.test.ts +++ b/test/yaml-support/tkn-yaml-scheme-generator.test.ts @@ -21,7 +21,7 @@ chai.use(sinonChai); suite('Pipeline scheme generator', () => { const sandbox = sinon.createSandbox(); - let getRawTasksStub: sinon.SinonStub<[], Promise>; + let getRawTasksStub: sinon.SinonStub; setup(() => { sandbox.stub(tasksProvider, 'getTknTasksSnippets').resolves([]);