You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

447 lines
13 KiB

'use strict';
import { Command, Disposable, Event, TreeItem, TreeItemCollapsibleState, TreeViewVisibilityChangeEvent } from 'vscode';
import { GlyphChars } from '../../constants';
import { Container } from '../../container';
import {
GitFile,
GitReference,
GitRevisionReference,
Repository,
RepositoryChange,
RepositoryChangeComparisonMode,
RepositoryChangeEvent,
} from '../../git/git';
import { GitUri } from '../../git/gitUri';
import { Logger } from '../../logger';
import { debug, Functions, gate, log, logName, Strings } from '../../system';
import { TreeViewNodeCollapsibleStateChangeEvent, View } from '../viewBase';
export enum ContextValues {
ActiveFileHistory = 'gitlens:history:active:file',
ActiveLineHistory = 'gitlens:history:active:line',
Branch = 'gitlens:branch',
Branches = 'gitlens:branches',
BranchStatusAheadOfUpstream = 'gitlens:status-branch:upstream:ahead',
BranchStatusBehindUpstream = 'gitlens:status-branch:upstream:behind',
BranchStatusNoUpstream = 'gitlens:status-branch:upstream:none',
BranchStatusSameAsUpstream = 'gitlens:status-branch:upstream:same',
BranchStatusFiles = 'gitlens:status-branch:files',
Commit = 'gitlens:commit',
Commits = 'gitlens:commits',
Compare = 'gitlens:compare',
CompareBranch = 'gitlens:compare:branch',
ComparePicker = 'gitlens:compare:picker',
ComparePickerWithRef = 'gitlens:compare:picker:ref',
CompareResults = 'gitlens:compare:results',
CompareResultsCommits = 'gitlens:compare:results:commits',
Contributor = 'gitlens:contributor',
Contributors = 'gitlens:contributors',
DateMarker = 'gitlens:date-marker',
File = 'gitlens:file',
FileHistory = 'gitlens:history:file',
Folder = 'gitlens:folder',
LineHistory = 'gitlens:history:line',
Merge = 'gitlens:merge',
MergeConflictCurrentChanges = 'gitlens:merge-conflict:current',
MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming',
Message = 'gitlens:message',
Pager = 'gitlens:pager',
PullRequest = 'gitlens:pullrequest',
Rebase = 'gitlens:rebase',
Reflog = 'gitlens:reflog',
ReflogRecord = 'gitlens:reflog-record',
Remote = 'gitlens:remote',
Remotes = 'gitlens:remotes',
Repositories = 'gitlens:repositories',
Repository = 'gitlens:repository',
RepositoryFolder = 'gitlens:repo-folder',
ResultsFile = 'gitlens:file:results',
ResultsFiles = 'gitlens:results:files',
SearchAndCompare = 'gitlens:searchAndCompare',
SearchResults = 'gitlens:search:results',
SearchResultsCommits = 'gitlens:search:results:commits',
Stash = 'gitlens:stash',
Stashes = 'gitlens:stashes',
StatusFileCommits = 'gitlens:status:file:commits',
StatusFiles = 'gitlens:status:files',
StatusAheadOfUpstream = 'gitlens:status:upstream:ahead',
StatusBehindUpstream = 'gitlens:status:upstream:behind',
StatusNoUpstream = 'gitlens:status:upstream:none',
StatusSameAsUpstream = 'gitlens:status:upstream:same',
Tag = 'gitlens:tag',
Tags = 'gitlens:tags',
}
export const unknownGitUri = new GitUri();
export interface ViewNode {
readonly id?: string;
}
@logName<ViewNode>((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`)
export abstract class ViewNode<TView extends View = View> {
static is(node: any): node is ViewNode {
return node instanceof ViewNode;
}
protected splatted = false;
constructor(uri: GitUri, public readonly view: TView, protected readonly parent?: ViewNode) {
this._uri = uri;
}
toClipboard?(): string;
toString(): string {
return `${Logger.toLoggableName(this)}${this.id != null ? `(${this.id})` : ''}`;
}
protected _uri: GitUri;
get uri() {
return this._uri;
}
abstract getChildren(): ViewNode[] | Promise<ViewNode[]>;
getParent(): ViewNode | undefined {
// If this node's parent has been splatted (e.g. not shown itself, but its children are), then return its grandparent
return this.parent?.splatted ? this.parent?.getParent() : this.parent;
}
abstract getTreeItem(): TreeItem | Promise<TreeItem>;
getCommand(): Command | undefined {
return undefined;
}
refresh?(reset?: boolean): boolean | void | Promise<void> | Promise<boolean>;
@gate<RepositoryFolderNode['triggerChange']>(
(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode) =>
JSON.stringify([reset, force, avoidSelf?.toString()]),
)
@debug()
triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise<void> {
// If this node has been splatted (e.g. not shown itself, but its children are), then delegate the change to its parent
if (this.splatted && this.parent != null && this.parent !== avoidSelf) {
return this.parent.triggerChange(reset, force);
}
return this.view.refreshNode(this, reset, force);
}
getSplattedChild?(): Promise<ViewNode | undefined>;
}
export abstract class ViewRefNode<
TView extends View = View,
TReference extends GitReference = GitReference
> extends ViewNode<TView> {
abstract get ref(): TReference;
get repoPath(): string {
return this.uri.repoPath!;
}
toString(): string {
return `${super.toString()}:${GitReference.toString(this.ref, false)}`;
}
}
export abstract class ViewRefFileNode<TView extends View = View> extends ViewRefNode<TView, GitRevisionReference> {
abstract get file(): GitFile;
abstract get fileName(): string;
toString(): string {
return `${super.toString()}:${this.fileName}`;
}
}
export function nodeSupportsClearing(node: ViewNode): node is ViewNode & { clear(): void | Promise<void> } {
return typeof (node as ViewNode & { clear(): void | Promise<void> }).clear === 'function';
}
export interface PageableViewNode {
readonly id: string;
limit?: number;
readonly hasMore: boolean;
loadMore(limit?: number | { until?: any }): Promise<void>;
}
export namespace PageableViewNode {
export function is(node: ViewNode): node is ViewNode & PageableViewNode {
return Functions.is<ViewNode & PageableViewNode>(node, 'loadMore');
}
}
export abstract class SubscribeableViewNode<TView extends View = View> extends ViewNode<TView> {
protected disposable: Disposable;
protected subscription: Promise<Disposable | undefined> | undefined;
protected loaded: boolean = false;
constructor(uri: GitUri, view: TView, parent?: ViewNode) {
super(uri, view, parent);
const disposables = [
this.view.onDidChangeVisibility(this.onVisibilityChanged, this),
this.view.onDidChangeNodeCollapsibleState(this.onNodeCollapsibleStateChanged, this),
];
if (viewSupportsAutoRefresh(this.view)) {
disposables.push(this.view.onDidChangeAutoRefresh(this.onAutoRefreshChanged, this));
}
const getTreeItem = this.getTreeItem;
this.getTreeItem = function (this: SubscribeableViewNode<TView>) {
this.loaded = true;
void this.ensureSubscription();
return getTreeItem.apply(this);
};
const getChildren = this.getChildren;
this.getChildren = function (this: SubscribeableViewNode<TView>) {
this.loaded = true;
void this.ensureSubscription();
return getChildren.apply(this);
};
this.disposable = Disposable.from(...disposables);
}
@debug()
dispose() {
void this.unsubscribe();
this.disposable?.dispose();
}
@gate()
@debug()
async triggerChange(reset: boolean = false, force: boolean = false): Promise<void> {
if (!this.loaded) return;
await super.triggerChange(reset, force);
}
private _canSubscribe: boolean = true;
protected get canSubscribe(): boolean {
return this._canSubscribe;
}
protected set canSubscribe(value: boolean) {
if (this._canSubscribe === value) return;
this._canSubscribe = value;
void this.ensureSubscription();
if (value) {
void this.triggerChange();
}
}
protected get requiresResetOnVisible(): boolean {
return false;
}
protected abstract subscribe(): Disposable | undefined | Promise<Disposable | undefined>;
@debug()
protected async unsubscribe(): Promise<void> {
if (this.subscription != null) {
const subscriptionPromise = this.subscription;
this.subscription = undefined;
(await subscriptionPromise)?.dispose();
}
}
@debug()
protected onAutoRefreshChanged() {
this.onVisibilityChanged({ visible: this.view.visible });
}
protected onParentCollapsibleStateChanged?(state: TreeItemCollapsibleState): void;
protected onCollapsibleStateChanged?(state: TreeItemCollapsibleState): void;
protected collapsibleState: TreeItemCollapsibleState | undefined;
protected onNodeCollapsibleStateChanged(e: TreeViewNodeCollapsibleStateChangeEvent<ViewNode>) {
if (e.element === this) {
this.collapsibleState = e.state;
if (this.onCollapsibleStateChanged !== undefined) {
this.onCollapsibleStateChanged(e.state);
}
} else if (e.element === this.parent) {
if (this.onParentCollapsibleStateChanged !== undefined) {
this.onParentCollapsibleStateChanged(e.state);
}
}
}
@debug()
protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) {
void this.ensureSubscription();
if (e.visible) {
void this.triggerChange(this.requiresResetOnVisible);
}
}
@gate()
@debug()
async ensureSubscription() {
// We only need to subscribe if we are visible and if auto-refresh enabled (when supported)
if (
!this.canSubscribe ||
!this.view.visible ||
(viewSupportsAutoRefresh(this.view) && !this.view.autoRefresh)
) {
await this.unsubscribe();
return;
}
// If we already have a subscription, just kick out
if (this.subscription != null) return;
this.subscription = Promise.resolve(this.subscribe());
await this.subscription;
}
@gate()
@debug()
async resetSubscription() {
await this.unsubscribe();
await this.ensureSubscription();
}
}
export abstract class RepositoryFolderNode<
TView extends View = View,
TChild extends ViewNode = ViewNode
> extends SubscribeableViewNode<TView> {
static key = ':repository';
static getId(repoPath: string): string {
return `gitlens${this.key}(${repoPath})`;
}
protected splatted = true;
protected child: TChild | undefined;
constructor(uri: GitUri, view: TView, parent: ViewNode, public readonly repo: Repository, splatted: boolean) {
super(uri, view, parent);
this.splatted = splatted;
}
toClipboard(): string {
return this.repo.path;
}
get id(): string {
return RepositoryFolderNode.getId(this.repo.path);
}
async getTreeItem(): Promise<TreeItem> {
this.splatted = false;
let expand = this.repo.starred;
if (!expand) {
expand = await Container.git.isActiveRepoPath(this.uri.repoPath);
}
const item = new TreeItem(
this.repo.formattedName ?? this.uri.repoPath ?? '',
expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed,
);
item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`;
item.description = this.repo.supportsChangeEvents ? undefined : Strings.pad(GlyphChars.Warning, 1, 0);
item.tooltip = `${
this.repo.formattedName ? `${this.repo.formattedName}\n${this.uri.repoPath}` : this.uri.repoPath ?? ''
}${
this.repo.supportsChangeEvents
? ''
: `\n\n${GlyphChars.Warning} Unable to automatically detect repository changes`
}`;
return item;
}
async getSplattedChild() {
if (this.child == null) {
await this.getChildren();
}
return this.child;
}
@gate()
@debug()
async refresh(reset: boolean = false) {
await this.child?.triggerChange(reset, false, this);
await this.ensureSubscription();
}
@log()
async star() {
await this.repo.star();
// void this.parent!.triggerChange();
}
@log()
async unstar() {
await this.repo.unstar();
// void this.parent!.triggerChange();
}
@debug()
protected subscribe(): Disposable | Promise<Disposable> {
return this.repo.onDidChange(this.onRepositoryChanged, this);
}
protected get requiresResetOnVisible(): boolean {
return this._repoUpdatedAt !== this.repo.updatedAt;
}
private _repoUpdatedAt: number = this.repo.updatedAt;
protected abstract changed(e: RepositoryChangeEvent): boolean;
@debug({
args: {
0: (e: RepositoryChangeEvent) => e.toString(),
},
})
private onRepositoryChanged(e: RepositoryChangeEvent) {
this._repoUpdatedAt = this.repo.updatedAt;
if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) {
this.dispose();
void this.parent?.triggerChange(true);
return;
}
if (e.changed(RepositoryChange.Starred, RepositoryChangeComparisonMode.Any)) {
void this.parent?.triggerChange(true);
return;
}
if (this.changed(e)) {
void (this.loaded ? this : this.parent ?? this).triggerChange(true);
}
}
}
interface AutoRefreshableView {
autoRefresh: boolean;
onDidChangeAutoRefresh: Event<void>;
}
export function viewSupportsAutoRefresh(view: View): view is View & AutoRefreshableView {
return Functions.is<View & AutoRefreshableView>(view, 'onDidChangeAutoRefresh');
}
export function viewSupportsNodeDismissal(view: View): view is View & { dismissNode(node: ViewNode): void } {
return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function';
}