import { CancellationToken, ConfigurationChangeEvent, Disposable, Event, EventEmitter, MarkdownString, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, TreeViewExpansionEvent, TreeViewVisibilityChangeEvent, window, } from 'vscode'; import { BranchesViewConfig, CommitsViewConfig, configuration, ContributorsViewConfig, FileHistoryViewConfig, LineHistoryViewConfig, RemotesViewConfig, RepositoriesViewConfig, SearchAndCompareViewConfig, StashesViewConfig, TagsViewConfig, ViewsCommonConfig, viewsCommonConfigKeys, viewsConfigKeys, ViewsConfigKeys, WorktreesViewConfig, } from '../configuration'; import { Container } from '../container'; import { Logger } from '../logger'; import { executeCommand } from '../system/command'; import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; import { debounce } from '../system/function'; import { cancellable, isPromise } from '../system/promise'; import { BranchesView } from './branchesView'; import { CommitsView } from './commitsView'; import { ContributorsView } from './contributorsView'; import { FileHistoryView } from './fileHistoryView'; import { LineHistoryView } from './lineHistoryView'; import { PageableViewNode, ViewNode } from './nodes'; import { RemotesView } from './remotesView'; import { RepositoriesView } from './repositoriesView'; import { SearchAndCompareView } from './searchAndCompareView'; import { StashesView } from './stashesView'; import { TagsView } from './tagsView'; import { WorktreesView } from './worktreesView'; export type View = | BranchesView | CommitsView | ContributorsView | FileHistoryView | LineHistoryView | RemotesView | RepositoriesView | SearchAndCompareView | StashesView | TagsView | WorktreesView; export type ViewsWithCommits = Exclude; export type ViewsWithRepositoryFolders = Exclude; export interface TreeViewNodeCollapsibleStateChangeEvent extends TreeViewExpansionEvent { state: TreeItemCollapsibleState; } export abstract class ViewBase< RootNode extends ViewNode, ViewConfig extends | BranchesViewConfig | ContributorsViewConfig | FileHistoryViewConfig | CommitsViewConfig | LineHistoryViewConfig | RemotesViewConfig | RepositoriesViewConfig | SearchAndCompareViewConfig | StashesViewConfig | TagsViewConfig | WorktreesViewConfig, > 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 disposables: Disposable[] = []; protected root: RootNode | undefined; protected tree: TreeView | undefined; private readonly _lastKnownLimits = new Map(); constructor( public readonly id: `gitlens.views.${string}`, public readonly name: string, public readonly container: Container, ) { this.disposables.push(once(container.onReady)(this.onReady, this)); if (this.container.debugging || this.container.config.debug) { function addDebuggingInfo(item: TreeItem, node: ViewNode, parent: ViewNode | undefined) { if (item.tooltip == null) { item.tooltip = new MarkdownString( item.label != null && typeof item.label !== 'string' ? item.label.label : item.label ?? '', ); } if (typeof item.tooltip === 'string') { item.tooltip = `${item.tooltip}\n\n---\ncontext: ${item.contextValue}\nnode: ${node.toString()}${ parent != null ? `\nparent: ${parent.toString()}` : '' }`; } else { item.tooltip.appendMarkdown( `\n\n---\n\ncontext: \`${item.contextValue}\`\\\nnode: \`${node.toString()}\`${ parent != null ? `\\\nparent: \`${parent.toString()}\`` : '' }`, ); } } const getTreeItemFn = this.getTreeItem; this.getTreeItem = async function (this: ViewBase, node: ViewNode) { const item = await getTreeItemFn.apply(this, [node]); const parent = node.getParent(); if (node.resolveTreeItem != null) { if (item.tooltip != null) { addDebuggingInfo(item, node, parent); } const resolveTreeItemFn = node.resolveTreeItem; node.resolveTreeItem = async function (this: ViewBase, item: TreeItem) { const resolvedItem = await resolveTreeItemFn.apply(this, [item]); addDebuggingInfo(resolvedItem, node, parent); return resolvedItem; }; } else { addDebuggingInfo(item, node, parent); } return item; }; } this.disposables.push(...this.registerCommands()); } dispose() { Disposable.from(...this.disposables).dispose(); } private onReady() { this.initialize({ showCollapseAll: this.showCollapseAll }); queueMicrotask(() => this.onConfigurationChanged()); } get canReveal(): boolean { return true; } protected get showCollapseAll(): boolean { return true; } protected filterConfigurationChanged(e: ConfigurationChangeEvent) { if (!configuration.changed(e, 'views')) return false; if (configuration.changed(e, `views.${this.configKey}` as const)) return true; for (const key of viewsCommonConfigKeys) { if (configuration.changed(e, `views.${key}` as const)) return true; } return false; } 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; } } private _message: string | undefined; get message(): string | undefined { return this._message; } set message(value: string | undefined) { this._message = value; if (this.tree != null) { this.tree.message = value; } } getQualifiedCommand(command: string) { return `${this.id}.${command}`; } protected abstract getRoot(): RootNode; protected abstract registerCommands(): Disposable[]; protected onConfigurationChanged(e?: ConfigurationChangeEvent): void { if (e != null && this.root != null) { void this.refresh(true); } } protected initialize(options: { showCollapseAll?: boolean } = {}) { this.tree = window.createTreeView>(this.id, { ...options, treeDataProvider: this, }); this.disposables.push( configuration.onDidChange(e => { if (!this.filterConfigurationChanged(e)) return; this._config = undefined; this.onConfigurationChanged(e); }, this), this.tree, this.tree.onDidChangeVisibility(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(); } resolveTreeItem(item: TreeItem, node: ViewNode): TreeItem | Promise { return node.resolveTreeItem?.(item) ?? item; } 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(): readonly ViewNode[] { if (this.tree == null || this.root == null) return []; return this.tree.selection; } get visible(): boolean { return 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['findNode']>({ args: { 0: predicate => (typeof predicate === 'string' ? predicate : ''), 1: opts => `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(); async function find(this: ViewBase) { 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; } } if (this.root != null) return find.call(this); // If we have no root (e.g. never been initialized) force it so the tree will load properly await this.show({ preserveFocus: true }); // Since we have to show the view, give the view time to load and let the callstack unwind before we try to find the node return new Promise(resolve => setTimeout(() => resolve(find.call(this)), 100)); } 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 = this.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 (isPromise(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 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['refreshNode']>({ args: { 0: n => 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['reveal']>({ args: { 0: n => 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(options?: { preserveFocus?: boolean }) { const cc = Logger.getCorrelationContext(); try { void (await executeCommand(`${this.id}.focus`, options)); } catch (ex) { Logger.error(ex, cc); } } // @debug({ args: { 0: (n: ViewNode) => n.toString() }, singleLine: true }) getNodeLastKnownLimit(node: PageableViewNode) { return this._lastKnownLimits.get(node.id); } @debug['loadMoreNodeChildren']>({ args: { 0: n => n.toString(), 2: n => n?.toString() }, }) async loadMoreNodeChildren( node: ViewNode & PageableViewNode, limit: number | { until: string | undefined } | 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['resetNodeLastKnownLimit']>({ args: { 0: n => n.toString() }, singleLine: true, }) resetNodeLastKnownLimit(node: PageableViewNode) { this._lastKnownLimits.delete(node.id); } @debug['triggerNodeChange']>({ args: { 0: n => 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 = { ...this.container.config.views }; for (const view of viewsConfigKeys) { delete cfg[view]; } this._config = { ...(cfg as ViewsCommonConfig), ...(this.container.config.views[this.configKey] as ViewConfig), }; } return this._config; } }