'use strict'; import { CancellationToken, commands, ConfigurationChangeEvent, ConfigurationTarget, Disposable, Event, EventEmitter, MessageItem, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, TreeViewExpansionEvent, TreeViewVisibilityChangeEvent, window, } from 'vscode'; import { BranchesView } from './branchesView'; import { CommitsView } from './commitsView'; import { CompareView } from './compareView'; import { BranchesViewConfig, CommitsViewConfig, CompareViewConfig, configuration, ContributorsViewConfig, FileHistoryViewConfig, LineHistoryViewConfig, RemotesViewConfig, RepositoriesViewConfig, SearchViewConfig, StashesViewConfig, TagsViewConfig, ViewsCommonConfig, viewsCommonConfigKeys, viewsConfigKeys, ViewsConfigKeys, } from '../configuration'; import { Container } from '../container'; import { ContributorsView } from './contributorsView'; import { FileHistoryView } from './fileHistoryView'; import { LineHistoryView } from './lineHistoryView'; import { Logger } from '../logger'; import { PageableViewNode, ViewNode } from './nodes'; import { RemotesView } from './remotesView'; import { RepositoriesView } from './repositoriesView'; import { SearchView } from './searchView'; import { StashesView } from './stashesView'; import { debug, Functions, log, Promises, Strings } from '../system'; import { TagsView } from './tagsView'; export type View = | BranchesView | CompareView | ContributorsView | FileHistoryView | CommitsView | LineHistoryView | RemotesView | RepositoriesView | SearchView | StashesView | TagsView; export type ViewsWithFiles = | BranchesView | CompareView | ContributorsView | CommitsView | RemotesView | RepositoriesView | SearchView | StashesView | TagsView; export interface TreeViewNodeCollapsibleStateChangeEvent extends TreeViewExpansionEvent { state: TreeItemCollapsibleState; } export abstract class ViewBase< RootNode extends ViewNode, ViewConfig extends | BranchesViewConfig | CompareViewConfig | ContributorsViewConfig | FileHistoryViewConfig | CommitsViewConfig | LineHistoryViewConfig | RemotesViewConfig | RepositoriesViewConfig | SearchViewConfig | StashesViewConfig | TagsViewConfig > implements TreeDataProvider, Disposable { protected _onDidChangeTreeData = new EventEmitter(); get onDidChangeTreeData(): Event { return this._onDidChangeTreeData.event; } private _onDidChangeVisibility = new EventEmitter(); get onDidChangeVisibility(): Event { return this._onDidChangeVisibility.event; } private _onDidChangeNodeCollapsibleState = new EventEmitter>(); get onDidChangeNodeCollapsibleState(): Event> { return this._onDidChangeNodeCollapsibleState.event; } protected disposable: Disposable | undefined; protected root: RootNode | undefined; protected tree: TreeView | undefined; protected readonly showCollapseAll: boolean = true; private readonly _lastKnownLimits = new Map(); constructor(public readonly id: string, public readonly name: string) { if (Logger.isDebugging) { const getTreeItem = this.getTreeItem; this.getTreeItem = async function (this: ViewBase, node: ViewNode) { const item = await getTreeItem.apply(this, [node]); const parent = node.getParent(); if (parent != null) { item.tooltip = `${ item.tooltip ?? item.label }\n\nDBG:\nnode: ${node.toString()}\nparent: ${parent.toString()}\ncontext: ${item.contextValue}`; } else { item.tooltip = `${item.tooltip ?? item.label}\n\nDBG:\nnode: ${node.toString()}\ncontext: ${ item.contextValue }`; } return item; }; } this.registerCommands(); Container.context.subscriptions.push( configuration.onDidChange(e => { if (!this.filterConfigurationChanged(e)) return; this._config = undefined; this.onConfigurationChanged(e); }, this), ); this.initialize({ showCollapseAll: this.showCollapseAll }); setImmediate(() => this.onConfigurationChanged(configuration.initializingChangeEvent)); } protected filterConfigurationChanged(e: ConfigurationChangeEvent) { if (!configuration.changed(e, 'views')) return false; if (configuration.changed(e, 'views', this.configKey)) return true; for (const key of viewsCommonConfigKeys) { if (configuration.changed(e, 'views', key)) return true; } return false; } dispose() { this.disposable?.dispose(); } private _title: string | undefined; get title(): string | undefined { return this._title; } set title(value: string | undefined) { this._title = value; if (this.tree != null) { this.tree.title = value; } } private _description: string | undefined; get description(): string | undefined { return this._description; } set description(value: string | undefined) { this._description = value; if (this.tree != null) { this.tree.description = value; } } getQualifiedCommand(command: string) { return `${this.id}.${command}`; } protected abstract getRoot(): RootNode; protected abstract registerCommands(): void; protected onConfigurationChanged(e: ConfigurationChangeEvent): void { if (!configuration.initializing(e) && this.root != null) { void this.refresh(true); } } protected initialize(options: { showCollapseAll?: boolean } = {}) { if (this.disposable != null) { this.disposable.dispose(); this._onDidChangeTreeData = new EventEmitter(); } this.tree = window.createTreeView(this.id, { ...options, treeDataProvider: this, }); this.disposable = Disposable.from( this.tree, this.tree.onDidChangeVisibility(Functions.debounce(this.onVisibilityChanged, 250), this), this.tree.onDidCollapseElement(this.onElementCollapsed, this), this.tree.onDidExpandElement(this.onElementExpanded, this), ); this._title = this.tree.title; } protected ensureRoot(force: boolean = false) { if (this.root == null || force) { this.root = this.getRoot(); } return this.root; } getChildren(node?: ViewNode): ViewNode[] | Promise { if (node != null) return node.getChildren(); const root = this.ensureRoot(); return root.getChildren(); } getParent(node: ViewNode): ViewNode | undefined { return node.getParent(); } getTreeItem(node: ViewNode): TreeItem | Promise { return node.getTreeItem(); } protected onElementCollapsed(e: TreeViewExpansionEvent) { this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Collapsed }); } protected onElementExpanded(e: TreeViewExpansionEvent) { this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Expanded }); } protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { this._onDidChangeVisibility.fire(e); } get selection(): ViewNode[] { if (this.tree == null || this.root == null) return []; return this.tree.selection; } get visible(): boolean { return this.tree != null ? this.tree.visible : false; } async findNode( id: string, options?: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; }, ): Promise; async findNode( predicate: (node: ViewNode) => boolean, options?: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; }, ): Promise; @log({ args: { 0: (predicate: string | ((node: ViewNode) => boolean)) => typeof predicate === 'string' ? predicate : 'function', 1: (opts: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; }) => `options=${JSON.stringify({ ...opts, canTraverse: undefined, token: undefined })}`, }, }) async findNode( predicate: string | ((node: ViewNode) => boolean), { allowPaging = false, canTraverse, maxDepth = 2, token, }: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; } = {}, ): Promise { const cc = Logger.getCorrelationContext(); // If we have no root (e.g. never been initialized) force it so the tree will load properly if (this.root == null) { await this.show(); } try { const node = await this.findNodeCoreBFS( typeof predicate === 'string' ? n => n.id === predicate : predicate, this.ensureRoot(), allowPaging, canTraverse, maxDepth, token, ); return node; } catch (ex) { Logger.error(ex, cc); return undefined; } } private async findNodeCoreBFS( predicate: (node: ViewNode) => boolean, root: ViewNode, allowPaging: boolean, canTraverse: ((node: ViewNode) => boolean | Promise) | undefined, maxDepth: number, token: CancellationToken | undefined, ): Promise { const queue: (ViewNode | undefined)[] = [root, undefined]; const defaultPageSize = Container.config.advanced.maxListItems; let depth = 0; let node: ViewNode | undefined; let children: ViewNode[]; let pagedChildren: ViewNode[]; while (queue.length > 1) { if (token?.isCancellationRequested) return undefined; node = queue.shift(); if (node == null) { depth++; queue.push(undefined); if (depth > maxDepth) break; continue; } if (predicate(node)) return node; if (canTraverse != null) { const traversable = canTraverse(node); if (Promises.is(traversable)) { if (!(await traversable)) continue; } else if (!traversable) { continue; } } children = await node.getChildren(); if (children.length === 0) continue; while (node != null && !PageableViewNode.is(node)) { node = await node.getSplattedChild?.(); } if (node != null && PageableViewNode.is(node)) { let child = children.find(predicate); if (child != null) return child; if (allowPaging && node.hasMore) { while (true) { if (token?.isCancellationRequested) return undefined; await this.loadMoreNodeChildren(node, defaultPageSize); pagedChildren = await Promises.cancellable( Promise.resolve(node.getChildren()), token ?? 60000, { onDidCancel: resolve => resolve([]), }, ); child = pagedChildren.find(predicate); if (child != null) return child; if (!node.hasMore) break; } } // Don't traverse into paged children continue; } queue.push(...children); } return undefined; } protected async ensureRevealNode( node: ViewNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number; }, ) { // Not sure why I need to reveal each parent, but without it the node won't be revealed const nodes: ViewNode[] = []; let parent: ViewNode | undefined = node; while (parent != null) { nodes.push(parent); parent = parent.getParent(); } if (nodes.length > 1) { nodes.pop(); } for (const n of nodes.reverse()) { try { await this.reveal(n, options); } catch {} } } @debug() async refresh(reset: boolean = false) { await this.root?.refresh?.(reset); this.triggerNodeChange(); } @debug({ args: { 0: (n: ViewNode) => n.toString() }, }) async refreshNode(node: ViewNode, reset: boolean = false, force: boolean = false) { const cancel = await node.refresh?.(reset); if (!force && cancel === true) return; this.triggerNodeChange(node); } @log({ args: { 0: (n: ViewNode) => n.toString() }, }) async reveal( node: ViewNode, options?: { select?: boolean; focus?: boolean; expand?: boolean | number; }, ) { if (this.tree == null) return; try { await this.tree.reveal(node, options); } catch (ex) { Logger.error(ex); } } @log() async show() { try { void (await commands.executeCommand(`${this.id}.focus`)); } catch (ex) { Logger.error(ex); const section = Strings.splitSingle(this.id, '.')[1]; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!configuration.get(section as any, 'enabled')) { const actions: MessageItem[] = [{ title: 'Enable' }, { title: 'Cancel', isCloseAffordance: true }]; const result = await window.showErrorMessage( `Unable to show the ${this.name} view since it's currently disabled. Would you like to enable it?`, ...actions, ); if (result === actions[0]) { await configuration.update(section as any, 'enabled', true, ConfigurationTarget.Global); void (await commands.executeCommand(`${this.id}.focus`)); } } } } // @debug({ args: { 0: (n: ViewNode) => n.toString() }, singleLine: true }) getNodeLastKnownLimit(node: PageableViewNode) { return this._lastKnownLimits.get(node.id); } @debug({ args: { 0: (n: ViewNode & PageableViewNode) => n.toString(), 3: (n?: ViewNode) => (n == null ? '' : n.toString()), }, }) async loadMoreNodeChildren( node: ViewNode & PageableViewNode, limit: number | { until: any } | undefined, previousNode?: ViewNode, ) { if (previousNode != null) { void (await this.reveal(previousNode, { select: true })); } await node.loadMore(limit); this._lastKnownLimits.set(node.id, node.limit); } @debug({ args: { 0: (n: ViewNode) => n.toString() }, singleLine: true }) resetNodeLastKnownLimit(node: PageableViewNode) { this._lastKnownLimits.delete(node.id); } @debug({ args: { 0: (n: ViewNode) => (n != null ? n.toString() : '') }, }) triggerNodeChange(node?: ViewNode) { // Since the root node won't actually refresh, force everything this._onDidChangeTreeData.fire(node != null && node !== this.root ? node : undefined); } protected abstract readonly configKey: ViewsConfigKeys; private _config: (ViewConfig & ViewsCommonConfig) | undefined; get config(): ViewConfig & ViewsCommonConfig { if (this._config == null) { const cfg = { ...Container.config.views }; for (const view of viewsConfigKeys) { delete cfg[view]; } this._config = { ...(cfg as ViewsCommonConfig), ...(Container.config.views[this.configKey] as ViewConfig) }; } return this._config; } }