diff --git a/media/refresh-dark.svg b/media/refresh-dark.svg new file mode 100644 index 0000000..9196015 --- /dev/null +++ b/media/refresh-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/refresh-light.svg b/media/refresh-light.svg new file mode 100644 index 0000000..7ab5a08 --- /dev/null +++ b/media/refresh-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/remove-dark.svg b/media/remove-dark.svg new file mode 100644 index 0000000..6e5f94a --- /dev/null +++ b/media/remove-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/remove-light.svg b/media/remove-light.svg new file mode 100644 index 0000000..8bdcb0d --- /dev/null +++ b/media/remove-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index a3d8d1c..98a44a4 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,22 @@ { "command": "poiex.dispose", "title": "Remove All Notes (DANGER)" + }, + { + "command": "iacAudit.refreshProjectTree", + "title": "Refresh", + "icon": { + "light": "media/refresh-dark.svg", + "dark": "media/refresh-light.svg" + } + }, + { + "command": "iacAudit.deleteTreeProject", + "title": "Delete project", + "icon": { + "light": "media/remove-dark.svg", + "dark": "media/remove-light.svg" + } } ], "configuration": { @@ -161,6 +177,20 @@ "group": "inline@2", "when": "commentController == poiex" } + ], + "view/title": [ + { + "command": "iacAudit.refreshProjectTree", + "when": "view == iacAudit && workspaceFolderCount > 0 && iacAudit.isProjectOpen == false", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "iacAudit.deleteTreeProject", + "when": "view == iacAudit && workspaceFolderCount > 0 && iacAudit.isProjectOpen == false", + "group": "inline" + } ] }, "viewsContainers": { @@ -288,4 +318,4 @@ "type": "git", "url": "https://github.com/doyensec/poiex.git" } -} +} \ No newline at end of file diff --git a/src/comments.ts b/src/comments.ts index f986181..cec8b83 100644 --- a/src/comments.ts +++ b/src/comments.ts @@ -613,11 +613,11 @@ export class IaCComments { } } - dispose() { - this.disposables.forEach((disposable) => { + async dispose() { + for (const disposable of this.disposables) { this.context.subscriptions.splice(this.context.subscriptions.indexOf(disposable), 1); - disposable.dispose(); - }); + await disposable.dispose(); + } this.disposed = true; } } diff --git a/src/constants.ts b/src/constants.ts index f93b510..3379711 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,9 @@ export const DIAGNOSTICS_CODENAME = 'poiex'; export const IAC_POI_MESSAGE = "IaC Point Of Intersection:"; export const INFRAMAP_TIMEOUT_MS = 10 * 1000; export const INFRAMAP_DOWNLOADED_STATENAME = "inframapDownloading"; +export const PROJECT_TREE_VIEW_TITLE = "PoiEx: Project List"; +export const PROJECT_TREE_VIEW_NO_PROJECTS_MESSAGE = "No projects found."; +export const PROJECT_TREE_VIEW_DB_ERROR_MESSAGE = "Invalid DB configuration."; export const FLAG_UNFLAGGED = 0; export const FLAG_FALSE = 1; diff --git a/src/extension.ts b/src/extension.ts index 8fd8375..badeafb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,7 @@ import { LocalDB } from './db'; import { RemoteDB } from './remote'; import * as comments from './comments'; import { IaCEncryption } from './encryption'; +import * as tree from './tree'; let db: LocalDB; let rdb: RemoteDB; @@ -20,6 +21,7 @@ let mComments: comments.IaCComments; let pdb: IaCProjectDir; let projectDisposables: vscode.Disposable[] = []; let projectClosing: boolean = false; +let projectTreeView: tree.ProjectTreeViewManager; export async function initLocalDb(dbDir: string, projectUuid: string) { // Create sqlite3 database in storage directory @@ -212,18 +214,57 @@ async function init1(context: vscode.ExtensionContext, iacPath: string) { })); // Register command to open an existing project - context.subscriptions.push(vscode.commands.registerCommand(`${constants.EXT_NAME}.openProject`, async () => { + context.subscriptions.push(vscode.commands.registerCommand(`${constants.EXT_NAME}.openProject`, async (projectUuid: string | undefined = undefined) => { console.log('[IaC Main] Open project button pressed'); if (!ensureDbOk()) { console.log('[IaC Main] Remote database not configured, cannot open project'); return; } + let cb2 = async (choice: string | undefined) => { + if (choice === undefined) { + return; + } + let projectUuid = choice; + if (choice.includes(' $ ')) { + projectUuid = choice.split(' $ ')[1]; + } + let project = await pdb.getProject(projectUuid); + assert(project !== null, "Project not found in local database"); + if (project === null) { return; }; + if (project[3] === null || project[2] !== null) { + openProject(context, iacUri, projectUuid); + return; + } + // Ask for project secret + vscode.window.showInputBox({ + placeHolder: 'Please enter project secret', + prompt: 'Please enter project secret', + password: true, + validateInput: (value: string) => { + if (value === undefined || value === '') { + return 'Project secret cannot be empty'; + } + return undefined; + } + }).then((projectSecret) => { + if (projectSecret === undefined) { + return; + } + openProject(context, iacUri, projectUuid, projectSecret); + }); + }; + let cb = async () => { console.log('[IaC Main] Open project ready, executing callback'); + if (projectUuid !== undefined) { + cb2(projectUuid); + return; + } // Show list of projects as quickpick let projectList = (await pdb.listProjects()) as {}[]; + projectTreeView.update(projectList); let projectNames = projectList.map((project: any) => project.name + " $ " + project.uuid); console.log('[IaC Main] Open project got list of projects'); if (projectNames.length === 0) { @@ -233,36 +274,7 @@ async function init1(context: vscode.ExtensionContext, iacPath: string) { vscode.window.showQuickPick(projectNames, { placeHolder: 'Please select a project to open' - }).then(async (choice) => { - if (choice === undefined) { - return; - } - let projectUuid = choice.split(' $ ')[1]; - let project = await pdb.getProject(projectUuid); - assert(project !== null, "Project not found in local database"); - if (project === null) { return; }; - if (project[3] === null || project[2] !== null) { - openProject(context, iacUri, projectUuid); - return; - } - // Ask for project secret - vscode.window.showInputBox({ - placeHolder: 'Please enter project secret', - prompt: 'Please enter project secret', - password: true, - validateInput: (value: string) => { - if (value === undefined || value === '') { - return 'Project secret cannot be empty'; - } - return undefined; - } - }).then((projectSecret) => { - if (projectSecret === undefined) { - return; - } - openProject(context, iacUri, projectUuid, projectSecret); - }); - }); + }).then(cb2); }; if (rdb.settingsEnabled()) { @@ -284,9 +296,24 @@ async function init1(context: vscode.ExtensionContext, iacPath: string) { let projectUuid = project.uuid; await pdb.removeProject(projectUuid, rdb); } + projectTreeView.update(); } }); - })); + })); + + // Experimental tree view + if (ensureDbOk()) { + projectTreeView = new tree.ProjectTreeViewManager(context, pdb, rdb); + context.subscriptions.push(projectTreeView); + projectTreeView.show(); + projectTreeView.update(); + } + else { + projectTreeView = new tree.ProjectTreeViewManager(context, pdb, undefined); + context.subscriptions.push(projectTreeView); + projectTreeView.showDbError(); + console.log('[IaC Main] Remote database not configured, cannot show project list'); + } } async function openProject(context: vscode.ExtensionContext, storageUri: vscode.Uri, projectUuid: string, projectSecret: string | null = null) { @@ -325,6 +352,9 @@ async function openProject(context: vscode.ExtensionContext, storageUri: vscode. vscode.commands.executeCommand('setContext', 'iacAudit.isProjectCreator', true); vscode.commands.executeCommand('setContext', 'iacAudit.isProjectEncrypted', projectSecret !== null); + // Hide project tree view + projectTreeView.hide(); + rdb.setProjectUuid(projectUuid, projectSecret); await initLocalDb(storageUri.fsPath, projectUuid); @@ -415,8 +445,8 @@ async function closeProject(context: vscode.ExtensionContext, projectUuid: strin } projectClosing = true; + await mComments.dispose(); mIacWebviewManager.dispose(); - mComments.dispose(); mIaCDiagnostics.dispose(); // Dispose all project disposables @@ -439,6 +469,11 @@ async function closeProject(context: vscode.ExtensionContext, projectUuid: strin vscode.commands.executeCommand('setContext', 'iacAudit.isProjectCreator', false); vscode.commands.executeCommand('setContext', 'iacAudit.isProjectEncrypted', false); + // Show project tree view + projectTreeView.show().then(() => { + projectTreeView.update(); + }); + // Race condition prevention projectClosing = false; } diff --git a/src/tree.ts b/src/tree.ts new file mode 100644 index 0000000..9f28921 --- /dev/null +++ b/src/tree.ts @@ -0,0 +1,203 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; + +import * as projects from './projects'; +import * as constants from './constants'; +import * as remote from './remote'; + +export class ProjectTreeViewManager { + private context: vscode.ExtensionContext; + private pdb: projects.IaCProjectDir; + private rdb: remote.RemoteDB | undefined; + private projectTreeView: vscode.TreeView | undefined = undefined; + private projectTreeDataProvider: ProjectSelectorProvider | undefined = undefined; + private originalViewTitle: string | undefined = undefined; + private _disposables: vscode.Disposable[] = []; + private hideProgressBar: any = undefined; + + constructor(context: vscode.ExtensionContext, pbd: projects.IaCProjectDir, rdb: remote.RemoteDB | undefined) { + this.context = context; + this.pdb = pbd; + this.rdb = rdb; + + this._disposables.push(vscode.commands.registerCommand('iacAudit.refreshProjectTree', async () => { + if (this.hideProgressBar !== undefined) { + console.log("[IaC Tree] Already refreshing project list"); + return; + } + + vscode.window.withProgress({ + location: { viewId: 'iacAudit' } + }, (progress, token) => { + return new Promise((resolve, reject) => { + this.hideProgressBar = resolve; + }); + }); + + try { + await this.update(); + } + catch (err: any) { + console.error(`[IaC Tree] Error refreshing project list: ${err}`); + } + finally { + if (this.hideProgressBar !== undefined) { + this.hideProgressBar(); + this.hideProgressBar = undefined; + } + } + })); + + this._disposables.push(vscode.commands.registerCommand('iacAudit.deleteTreeProject', async (project: ProjectItem) => { + console.log(`[IaC Tree] Deleting project ${project.uuid}`); + await this.pdb.removeProject(project.uuid, rdb); + await this.update(); + })); + } + + async show() { + this.projectTreeDataProvider = new ProjectSelectorProvider([]); + this.projectTreeView = vscode.window.createTreeView('iacAudit', { + treeDataProvider: this.projectTreeDataProvider, + }); + this.originalViewTitle = this.projectTreeView.title; + this.projectTreeView.title = constants.PROJECT_TREE_VIEW_TITLE; + this.projectTreeView.message = undefined; + this.projectTreeView.onDidChangeSelection(async (e) => { + if (e.selection.length !== 1) { + console.log(`[IaC Tree] Invalid selection length: ${e.selection}`); + return; + } + let project = e.selection[0]; + if (project instanceof ProjectItem) { + console.log(`[IaC Tree] Opening project ${project.uuid}`); + await vscode.commands.executeCommand(`${constants.EXT_NAME}.openProject`, project.uuid); + return; + } + console.log(`[IaC Tree] Invalid selection: ${e.selection}`); + }); + } + + async hide() { + if (this.projectTreeView === undefined) { + return; + } + this.projectTreeView.title = this.originalViewTitle || ""; + this.projectTreeView.message = undefined; + await this.projectTreeView?.dispose(); + } + + async showDbError() { + if (this.projectTreeView === undefined) { + await this.show(); + } + (this.projectTreeView as vscode.TreeView).message = constants.PROJECT_TREE_VIEW_DB_ERROR_MESSAGE; + } + + private async syncRemoteDB(): Promise { + if (this.rdb === undefined) { + return false; + } + + if (this.rdb.settingsEnabled()) { + // Promisify onDbReadyOnce + let onDbReadyOnce = (): Promise => { + let rrdb = this.rdb; + return new Promise(function(resolve, reject) { + if (rrdb === undefined) { + resolve(); + } + else { + rrdb.onDbReadyOnce(resolve); + } + }); + } + + try { + await onDbReadyOnce(); + await this.pdb.safeSyncProjects(this.rdb); + } + catch (err) { + console.error(`[IaC Tree] Error syncing remote DB: ${err}`); + return false; + } + return true; + } + else { + return false; + } + } + + async update(projectList: {}[] | null = null) { + if (projectList == null) { + await this.syncRemoteDB(); + projectList = (await this.pdb.listProjects()) as {}[]; + } + if (this.projectTreeView !== undefined) { + this.projectTreeDataProvider?.update(projectList); + if (projectList.length === 0) { + this.projectTreeView.message = constants.PROJECT_TREE_VIEW_NO_PROJECTS_MESSAGE; + } + } + } + + async dispose() { + await this.hide(); + + this._disposables.forEach((disposable) => { + disposable.dispose(); + }); + } +} + +class ProjectSelectorProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + constructor(private projectList: any[]) { } + + update(projectList: any[]) { + this.projectList = projectList; + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: ProjectItem): vscode.TreeItem { + return element; + } + + getChildren(element?: ProjectItem): Thenable { + if (element) { + return Promise.resolve([]); + } else { + return Promise.resolve(this.getProjects()); + } + } + + private getProjects(): ProjectItem[] { + return this.projectList.map( + (project: any) => + new ProjectItem(project.uuid, project.name, vscode.TreeItemCollapsibleState.None) + ); + } +} + +class ProjectItem extends vscode.TreeItem { + constructor( + public uuid: string, + private pname: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState + ) { + const label = pname; + super(label, collapsibleState); + this.tooltip = `${this.label} $ ${this.uuid}`; + this.description = this.uuid; + } + + // TODO: add icons for projects + //iconPath = { + // light: path.join(__filename, '..', '..', 'resources', 'light', 'dependency.svg'), + // dark: path.join(__filename, '..', '..', 'resources', 'dark', 'dependency.svg') + //}; +} \ No newline at end of file