@ -0,0 +1,36 @@ | |||
import type { TreeViewNodeTypes } from '../../../constants'; | |||
import { debug } from '../../../system/decorators/log'; | |||
import type { View } from '../../viewBase'; | |||
import { disposeChildren } from '../../viewBase'; | |||
import { ViewNode } from './viewNode'; | |||
export abstract class CacheableChildrenViewNode< | |||
Type extends TreeViewNodeTypes = TreeViewNodeTypes, | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
private _children: TChild[] | undefined; | |||
protected get children(): TChild[] | undefined { | |||
return this._children; | |||
} | |||
protected set children(value: TChild[] | undefined) { | |||
if (this._children === value) return; | |||
disposeChildren(this._children, value); | |||
this._children = value; | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
this.children = undefined; | |||
} | |||
@debug() | |||
override refresh(reset: boolean = false) { | |||
if (reset) { | |||
this.children = undefined; | |||
} | |||
} | |||
} |
@ -0,0 +1,51 @@ | |||
import { Disposable } from 'vscode'; | |||
import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; | |||
import { unknownGitUri } from '../../../git/gitUri'; | |||
import type { SubscriptionChangeEvent } from '../../../plus/subscription/subscriptionService'; | |||
import { debug } from '../../../system/decorators/log'; | |||
import { weakEvent } from '../../../system/event'; | |||
import { szudzikPairing } from '../../../system/function'; | |||
import type { View } from '../../viewBase'; | |||
import { SubscribeableViewNode } from './subscribeableViewNode'; | |||
import type { ViewNode } from './viewNode'; | |||
export abstract class RepositoriesSubscribeableNode< | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
> extends SubscribeableViewNode<'repositories', TView, TChild> { | |||
protected override splatted = true; | |||
constructor(view: TView) { | |||
super('repositories', unknownGitUri, view); | |||
} | |||
override async getSplattedChild() { | |||
if (this.children == null) { | |||
await this.getChildren(); | |||
} | |||
return this.children?.length === 1 ? this.children[0] : undefined; | |||
} | |||
protected override etag(): number { | |||
return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); | |||
} | |||
@debug() | |||
protected subscribe(): Disposable | Promise<Disposable> { | |||
return Disposable.from( | |||
weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), | |||
weakEvent(this.view.container.subscription.onDidChange, this.onSubscriptionChanged, this), | |||
); | |||
} | |||
private onRepositoriesChanged(_e: RepositoriesChangeEvent) { | |||
void this.triggerChange(true); | |||
} | |||
private onSubscriptionChanged(e: SubscriptionChangeEvent) { | |||
if (e.current.plan !== e.previous.plan) { | |||
void this.triggerChange(true); | |||
} | |||
} | |||
} |
@ -0,0 +1,213 @@ | |||
import type { Disposable } from 'vscode'; | |||
import { MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||
import { GlyphChars } from '../../../constants'; | |||
import type { GitUri } from '../../../git/gitUri'; | |||
import { GitRemote } from '../../../git/models/remote'; | |||
import type { RepositoryChangeEvent } from '../../../git/models/repository'; | |||
import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; | |||
import { gate } from '../../../system/decorators/gate'; | |||
import { debug, log } from '../../../system/decorators/log'; | |||
import { weakEvent } from '../../../system/event'; | |||
import { pad } from '../../../system/string'; | |||
import type { View } from '../../viewBase'; | |||
import { SubscribeableViewNode } from './subscribeableViewNode'; | |||
import type { ViewNode } from './viewNode'; | |||
import { ContextValues, getViewNodeId } from './viewNode'; | |||
export abstract class RepositoryFolderNode< | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
> extends SubscribeableViewNode<'repo-folder', TView> { | |||
protected override splatted = true; | |||
constructor( | |||
uri: GitUri, | |||
view: TView, | |||
protected override readonly parent: ViewNode, | |||
public readonly repo: Repository, | |||
splatted: boolean, | |||
private readonly options?: { showBranchAndLastFetched?: boolean }, | |||
) { | |||
super('repo-folder', uri, view, parent); | |||
this.updateContext({ repository: this.repo }); | |||
this._uniqueId = getViewNodeId(this.type, this.context); | |||
this.splatted = splatted; | |||
} | |||
private _child: TChild | undefined; | |||
protected get child(): TChild | undefined { | |||
return this._child; | |||
} | |||
protected set child(value: TChild | undefined) { | |||
if (this._child === value) return; | |||
this._child?.dispose(); | |||
this._child = value; | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
this.child = undefined; | |||
} | |||
override get id(): string { | |||
return this._uniqueId; | |||
} | |||
override toClipboard(): string { | |||
return this.repo.path; | |||
} | |||
get repoPath(): string { | |||
return this.repo.path; | |||
} | |||
async getTreeItem(): Promise<TreeItem> { | |||
this.splatted = false; | |||
const branch = await this.repo.getBranch(); | |||
const ahead = (branch?.state.ahead ?? 0) > 0; | |||
const behind = (branch?.state.behind ?? 0) > 0; | |||
const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); | |||
const item = new TreeItem( | |||
this.repo.formattedName ?? this.uri.repoPath ?? '', | |||
expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, | |||
); | |||
item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; | |||
if (ahead) { | |||
item.contextValue += '+ahead'; | |||
} | |||
if (behind) { | |||
item.contextValue += '+behind'; | |||
} | |||
if (this.view.type === 'commits' && this.view.state.filterCommits.get(this.repo.id)?.length) { | |||
item.contextValue += '+filtered'; | |||
} | |||
if (branch != null && this.options?.showBranchAndLastFetched) { | |||
const lastFetched = (await this.repo.getLastFetched()) ?? 0; | |||
const status = branch.getTrackingStatus(); | |||
item.description = `${status ? `${status}${pad(GlyphChars.Dot, 1, 1)}` : ''}${branch.name}${ | |||
lastFetched | |||
? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` | |||
: '' | |||
}`; | |||
let providerName; | |||
if (branch.upstream != null) { | |||
const providers = GitRemote.getHighlanderProviders( | |||
await this.view.container.git.getRemotesWithProviders(branch.repoPath), | |||
); | |||
providerName = providers?.length ? providers[0].name : undefined; | |||
} else { | |||
const remote = await branch.getRemote(); | |||
providerName = remote?.provider?.name; | |||
} | |||
item.tooltip = new MarkdownString( | |||
`${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ | |||
lastFetched | |||
? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( | |||
lastFetched, | |||
false, | |||
)}` | |||
: '' | |||
}${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ | |||
branch.name | |||
}${ | |||
branch.upstream != null | |||
? ` is ${branch.getTrackingStatus({ | |||
empty: branch.upstream.missing | |||
? `missing upstream $(git-branch) ${branch.upstream.name}` | |||
: `up to date with $(git-branch) ${branch.upstream.name}${ | |||
providerName ? ` on ${providerName}` : '' | |||
}`, | |||
expand: true, | |||
icons: true, | |||
separator: ', ', | |||
suffix: ` $(git-branch) ${branch.upstream.name}${ | |||
providerName ? ` on ${providerName}` : '' | |||
}`, | |||
})}` | |||
: `hasn't been published to ${providerName ?? 'a remote'}` | |||
}`, | |||
true, | |||
); | |||
} else { | |||
item.tooltip = `${ | |||
this.repo.formattedName ? `${this.repo.formattedName}\n${this.uri.repoPath}` : this.uri.repoPath ?? '' | |||
}`; | |||
} | |||
return item; | |||
} | |||
override async getSplattedChild() { | |||
if (this.child == null) { | |||
await this.getChildren(); | |||
} | |||
return this.child; | |||
} | |||
@gate() | |||
@debug() | |||
override async refresh(reset: boolean = false) { | |||
super.refresh(reset); | |||
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 weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this); | |||
} | |||
protected override etag(): number { | |||
return this.repo.etag; | |||
} | |||
protected abstract changed(e: RepositoryChangeEvent): boolean; | |||
@debug<RepositoryFolderNode['onRepositoryChanged']>({ args: { 0: e => e.toString() } }) | |||
private onRepositoryChanged(e: RepositoryChangeEvent) { | |||
if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { | |||
this.dispose(); | |||
void this.parent?.triggerChange(true); | |||
return; | |||
} | |||
if ( | |||
e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any) || | |||
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); | |||
} | |||
} | |||
} |
@ -0,0 +1,169 @@ | |||
import type { TreeViewVisibilityChangeEvent } from 'vscode'; | |||
import { Disposable } from 'vscode'; | |||
import type { TreeViewSubscribableNodeTypes } from '../../../constants'; | |||
import type { GitUri } from '../../../git/gitUri'; | |||
import { gate } from '../../../system/decorators/gate'; | |||
import { debug } from '../../../system/decorators/log'; | |||
import { weakEvent } from '../../../system/event'; | |||
import type { View } from '../../viewBase'; | |||
import { CacheableChildrenViewNode } from './cacheableChildrenViewNode'; | |||
import type { ViewNode } from './viewNode'; | |||
import { canAutoRefreshView } from './viewNode'; | |||
export abstract class SubscribeableViewNode< | |||
Type extends TreeViewSubscribableNodeTypes = TreeViewSubscribableNodeTypes, | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
State extends object = any, | |||
> extends CacheableChildrenViewNode<Type, TView, TChild, State> { | |||
protected disposable: Disposable; | |||
protected subscription: Promise<Disposable | undefined> | undefined; | |||
protected loaded: boolean = false; | |||
constructor(type: Type, uri: GitUri, view: TView, parent?: ViewNode) { | |||
super(type, uri, view, parent); | |||
const disposables = [ | |||
weakEvent(this.view.onDidChangeVisibility, this.onVisibilityChanged, this), | |||
// weak(this.view.onDidChangeNodeCollapsibleState, this.onNodeCollapsibleStateChanged, this), | |||
]; | |||
if (canAutoRefreshView(this.view)) { | |||
disposables.push(weakEvent(this.view.onDidChangeAutoRefresh, this.onAutoRefreshChanged, this)); | |||
} | |||
const getTreeItem = this.getTreeItem; | |||
this.getTreeItem = function (this: SubscribeableViewNode<Type, TView>) { | |||
this.loaded = true; | |||
void this.ensureSubscription(); | |||
return getTreeItem.apply(this); | |||
}; | |||
const getChildren = this.getChildren; | |||
this.getChildren = function (this: SubscribeableViewNode<Type, TView>) { | |||
this.loaded = true; | |||
void this.ensureSubscription(); | |||
return getChildren.apply(this); | |||
}; | |||
this.disposable = Disposable.from(...disposables); | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
void this.unsubscribe(); | |||
this.disposable?.dispose(); | |||
} | |||
@gate<ViewNode['triggerChange']>((reset, force) => `${reset}|${force}`) | |||
@debug() | |||
override async triggerChange(reset: boolean = false, force: boolean = false): Promise<void> { | |||
if (!this.loaded || this._disposed) return; | |||
if (reset && !this.view.visible) { | |||
this._pendingReset = reset; | |||
} | |||
await super.triggerChange(reset, force); | |||
} | |||
private _canSubscribe: boolean = true; | |||
protected get canSubscribe(): boolean { | |||
return this._canSubscribe && !this._disposed; | |||
} | |||
protected set canSubscribe(value: boolean) { | |||
if (this._canSubscribe === value) return; | |||
this._canSubscribe = value; | |||
void this.ensureSubscription(); | |||
if (value) { | |||
void this.triggerChange(); | |||
} | |||
} | |||
private _etag: number | undefined; | |||
protected abstract etag(): number; | |||
private _pendingReset: boolean = false; | |||
private get requiresResetOnVisible(): boolean { | |||
let reset = this._pendingReset; | |||
this._pendingReset = false; | |||
const etag = this.etag(); | |||
if (etag !== this._etag) { | |||
this._etag = etag; | |||
reset = true; | |||
} | |||
return reset; | |||
} | |||
protected abstract subscribe(): Disposable | undefined | Promise<Disposable | undefined>; | |||
@debug() | |||
protected async unsubscribe(): Promise<void> { | |||
this._etag = this.etag(); | |||
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 || (canAutoRefreshView(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()); | |||
void (await this.subscription); | |||
} | |||
@gate() | |||
@debug() | |||
async resetSubscription() { | |||
await this.unsubscribe(); | |||
await this.ensureSubscription(); | |||
} | |||
} |
@ -0,0 +1,33 @@ | |||
import type { TreeViewFileNodeTypes } from '../../../constants'; | |||
import type { GitUri } from '../../../git/gitUri'; | |||
import type { GitFile } from '../../../git/models/file'; | |||
import type { View } from '../../viewBase'; | |||
import { ViewNode } from './viewNode'; | |||
export abstract class ViewFileNode< | |||
Type extends TreeViewFileNodeTypes = TreeViewFileNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
constructor( | |||
type: Type, | |||
uri: GitUri, | |||
view: TView, | |||
public override parent: ViewNode, | |||
public readonly file: GitFile, | |||
) { | |||
super(type, uri, view, parent); | |||
} | |||
get repoPath(): string { | |||
return this.uri.repoPath!; | |||
} | |||
override toString(): string { | |||
return `${super.toString()}:${this.file.path}`; | |||
} | |||
} | |||
export function isViewFileNode(node: unknown): node is ViewFileNode { | |||
return node instanceof ViewFileNode; | |||
} |
@ -0,0 +1,408 @@ | |||
import type { Command, Disposable, Event, TreeItem } from 'vscode'; | |||
import type { TreeViewNodeTypes } from '../../../constants'; | |||
import type { GitUri } from '../../../git/gitUri'; | |||
import type { GitBranch } from '../../../git/models/branch'; | |||
import type { GitCommit } from '../../../git/models/commit'; | |||
import type { GitContributor } from '../../../git/models/contributor'; | |||
import type { GitFile } from '../../../git/models/file'; | |||
import type { GitReflogRecord } from '../../../git/models/reflog'; | |||
import type { GitRemote } from '../../../git/models/remote'; | |||
import type { Repository } from '../../../git/models/repository'; | |||
import type { GitTag } from '../../../git/models/tag'; | |||
import type { GitWorktree } from '../../../git/models/worktree'; | |||
import type { | |||
CloudWorkspace, | |||
CloudWorkspaceRepositoryDescriptor, | |||
LocalWorkspace, | |||
LocalWorkspaceRepositoryDescriptor, | |||
} from '../../../plus/workspaces/models'; | |||
import { gate } from '../../../system/decorators/gate'; | |||
import { debug, logName } from '../../../system/decorators/log'; | |||
import { is as isA } from '../../../system/function'; | |||
import { getLoggableName } from '../../../system/logger'; | |||
import type { View } from '../../viewBase'; | |||
import type { BranchNode } from '../branchNode'; | |||
import type { BranchTrackingStatus } from '../branchTrackingStatusNode'; | |||
import type { CommitFileNode } from '../commitFileNode'; | |||
import type { CommitNode } from '../commitNode'; | |||
import type { CompareBranchNode } from '../compareBranchNode'; | |||
import type { CompareResultsNode } from '../compareResultsNode'; | |||
import type { FileRevisionAsCommitNode } from '../fileRevisionAsCommitNode'; | |||
import type { FolderNode } from '../folderNode'; | |||
import type { LineHistoryTrackerNode } from '../lineHistoryTrackerNode'; | |||
import type { MergeConflictFileNode } from '../mergeConflictFileNode'; | |||
import type { RepositoryNode } from '../repositoryNode'; | |||
import type { ResultsCommitsNode } from '../resultsCommitsNode'; | |||
import type { ResultsFileNode } from '../resultsFileNode'; | |||
import type { StashFileNode } from '../stashFileNode'; | |||
import type { StashNode } from '../stashNode'; | |||
import type { StatusFileNode } from '../statusFileNode'; | |||
import type { TagNode } from '../tagNode'; | |||
import type { UncommittedFileNode } from '../UncommittedFileNode'; | |||
import type { RepositoryFolderNode } from './repositoryFolderNode'; | |||
export const enum ContextValues { | |||
ActiveFileHistory = 'gitlens:history:active:file', | |||
ActiveLineHistory = 'gitlens:history:active:line', | |||
AutolinkedItems = 'gitlens:autolinked:items', | |||
AutolinkedIssue = 'gitlens:autolinked:issue', | |||
AutolinkedItem = 'gitlens:autolinked:item', | |||
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', | |||
MessageSignIn = 'gitlens:message:signin', | |||
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', | |||
UncommittedFiles = 'gitlens:uncommitted:files', | |||
Workspace = 'gitlens:workspace', | |||
WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', | |||
Workspaces = 'gitlens:workspaces', | |||
Worktree = 'gitlens:worktree', | |||
Worktrees = 'gitlens:worktrees', | |||
} | |||
export interface AmbientContext { | |||
readonly autolinksId?: string; | |||
readonly branch?: GitBranch; | |||
readonly branchStatus?: BranchTrackingStatus; | |||
readonly branchStatusUpstreamType?: 'ahead' | 'behind' | 'same' | 'none'; | |||
readonly commit?: GitCommit; | |||
readonly comparisonId?: string; | |||
readonly comparisonFiltered?: boolean; | |||
readonly contributor?: GitContributor; | |||
readonly file?: GitFile; | |||
readonly reflog?: GitReflogRecord; | |||
readonly remote?: GitRemote; | |||
readonly repository?: Repository; | |||
readonly root?: boolean; | |||
readonly searchId?: string; | |||
readonly status?: 'merging' | 'rebasing'; | |||
readonly storedComparisonId?: string; | |||
readonly tag?: GitTag; | |||
readonly workspace?: CloudWorkspace | LocalWorkspace; | |||
readonly wsRepositoryDescriptor?: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; | |||
readonly worktree?: GitWorktree; | |||
} | |||
export function getViewNodeId(type: string, context: AmbientContext): string { | |||
let uniqueness = ''; | |||
if (context.root) { | |||
uniqueness += '/root'; | |||
} | |||
if (context.workspace != null) { | |||
uniqueness += `/ws/${context.workspace.id}`; | |||
} | |||
if (context.wsRepositoryDescriptor != null) { | |||
uniqueness += `/wsrepo/${context.wsRepositoryDescriptor.id}`; | |||
} | |||
if (context.repository != null) { | |||
uniqueness += `/repo/${context.repository.id}`; | |||
} | |||
if (context.worktree != null) { | |||
uniqueness += `/worktree/${context.worktree.uri.path}`; | |||
} | |||
if (context.remote != null) { | |||
uniqueness += `/remote/${context.remote.name}`; | |||
} | |||
if (context.tag != null) { | |||
uniqueness += `/tag/${context.tag.id}`; | |||
} | |||
if (context.branch != null) { | |||
uniqueness += `/branch/${context.branch.id}`; | |||
} | |||
if (context.branchStatus != null) { | |||
uniqueness += `/branch-status/${context.branchStatus.upstream ?? '-'}`; | |||
} | |||
if (context.branchStatusUpstreamType != null) { | |||
uniqueness += `/branch-status-direction/${context.branchStatusUpstreamType}`; | |||
} | |||
if (context.status != null) { | |||
uniqueness += `/status/${context.status}`; | |||
} | |||
if (context.reflog != null) { | |||
uniqueness += `/reflog/${context.reflog.sha}+${context.reflog.selector}+${context.reflog.command}+${ | |||
context.reflog.commandArgs ?? '' | |||
}+${context.reflog.date.getTime()}`; | |||
} | |||
if (context.contributor != null) { | |||
uniqueness += `/contributor/${ | |||
context.contributor.id ?? | |||
`${context.contributor.username}+${context.contributor.email}+${context.contributor.name}` | |||
}`; | |||
} | |||
if (context.autolinksId != null) { | |||
uniqueness += `/autolinks/${context.autolinksId}`; | |||
} | |||
if (context.comparisonId != null) { | |||
uniqueness += `/comparison/${context.comparisonId}`; | |||
} | |||
if (context.searchId != null) { | |||
uniqueness += `/search/${context.searchId}`; | |||
} | |||
if (context.commit != null) { | |||
uniqueness += `/commit/${context.commit.sha}`; | |||
} | |||
if (context.file != null) { | |||
uniqueness += `/file/${context.file.path}+${context.file.status}`; | |||
} | |||
return `gitlens://viewnode/${type}${uniqueness}`; | |||
} | |||
@logName<ViewNode>((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) | |||
export abstract class ViewNode< | |||
Type extends TreeViewNodeTypes = TreeViewNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> implements Disposable | |||
{ | |||
is<T extends keyof TreeViewNodesByType>(type: T): this is TreeViewNodesByType[T] { | |||
return this.type === (type as unknown as Type); | |||
} | |||
protected _uniqueId!: string; | |||
protected splatted = false; | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// readonly uuid = uuid(); | |||
constructor( | |||
public readonly type: Type, | |||
// public readonly id: string | undefined, | |||
uri: GitUri, | |||
public readonly view: TView, | |||
protected parent?: ViewNode, | |||
) { | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// queueMicrotask(() => this.view.registerNode(this)); | |||
this._uri = uri; | |||
} | |||
protected _disposed = false; | |||
@debug() | |||
dispose() { | |||
this._disposed = true; | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// this.view.unregisterNode(this); | |||
} | |||
get id(): string | undefined { | |||
return this._uniqueId; | |||
} | |||
private _context: AmbientContext | undefined; | |||
protected get context(): AmbientContext { | |||
return this._context ?? this.parent?.context ?? {}; | |||
} | |||
protected updateContext(context: AmbientContext, reset: boolean = false) { | |||
this._context = this.getNewContext(context, reset); | |||
} | |||
protected getNewContext(context: AmbientContext, reset: boolean = false) { | |||
return { ...(reset ? this.parent?.context : this.context), ...context }; | |||
} | |||
toClipboard?(): string; | |||
toString(): string { | |||
const id = this.id; | |||
return `${getLoggableName(this)}${id != null ? `(${id})` : ''}`; | |||
} | |||
protected _uri: GitUri; | |||
get uri(): GitUri { | |||
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>; | |||
resolveTreeItem?(item: TreeItem): TreeItem | Promise<TreeItem>; | |||
getCommand(): Command | undefined { | |||
return undefined; | |||
} | |||
refresh?(reset?: boolean): boolean | void | Promise<void> | Promise<boolean>; | |||
@gate<ViewNode['triggerChange']>((reset, force, avoidSelf) => `${reset}|${force}|${avoidSelf?.toString()}`) | |||
@debug() | |||
triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise<void> { | |||
if (this._disposed) return Promise.resolve(); | |||
// 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>; | |||
deleteState<T extends StateKey<State> = StateKey<State>>(key?: T): void { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to delete state'); | |||
} | |||
this.view.nodeState.deleteState(this.id, key as string); | |||
} | |||
getState<T extends StateKey<State> = StateKey<State>>(key: T): StateValue<State, T> | undefined { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to get state'); | |||
} | |||
return this.view.nodeState.getState(this.id, key as string); | |||
} | |||
storeState<T extends StateKey<State> = StateKey<State>>( | |||
key: T, | |||
value: StateValue<State, T>, | |||
sticky?: boolean, | |||
): void { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to store state'); | |||
} | |||
this.view.nodeState.storeState(this.id, key as string, value, sticky); | |||
} | |||
} | |||
type StateKey<T> = keyof T; | |||
type StateValue<T, P extends StateKey<T>> = P extends keyof T ? T[P] : never; | |||
export interface PageableViewNode extends ViewNode { | |||
readonly id: string; | |||
limit?: number; | |||
readonly hasMore: boolean; | |||
loadMore(limit?: number | { until?: string | undefined }, context?: Record<string, unknown>): Promise<void>; | |||
} | |||
export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { | |||
return isA<ViewNode & PageableViewNode>(node, 'loadMore'); | |||
} | |||
interface AutoRefreshableView { | |||
autoRefresh: boolean; | |||
onDidChangeAutoRefresh: Event<void>; | |||
} | |||
export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { | |||
return isA<View & AutoRefreshableView>(view, 'onDidChangeAutoRefresh'); | |||
} | |||
export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise<void> } { | |||
return typeof (node as ViewNode & { edit(): void | Promise<void> }).edit === 'function'; | |||
} | |||
export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { | |||
return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; | |||
} | |||
export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { | |||
return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; | |||
} | |||
export function getNodeRepoPath(node?: ViewNode): string | undefined { | |||
return canGetNodeRepoPath(node) ? node.repoPath : undefined; | |||
} | |||
type TreeViewNodesByType = { | |||
[T in TreeViewNodeTypes]: T extends 'branch' | |||
? BranchNode | |||
: T extends 'commit' | |||
? CommitNode | |||
: T extends 'commit-file' | |||
? CommitFileNode | |||
: T extends 'compare-branch' | |||
? CompareBranchNode | |||
: T extends 'compare-results' | |||
? CompareResultsNode | |||
: T extends 'conflict-file' | |||
? MergeConflictFileNode | |||
: T extends 'file-commit' | |||
? FileRevisionAsCommitNode | |||
: T extends 'folder' | |||
? FolderNode | |||
: T extends 'line-history-tracker' | |||
? LineHistoryTrackerNode | |||
: T extends 'repository' | |||
? RepositoryNode | |||
: T extends 'repo-folder' | |||
? RepositoryFolderNode | |||
: T extends 'results-commits' | |||
? ResultsCommitsNode | |||
: T extends 'results-file' | |||
? ResultsFileNode | |||
: T extends 'stash' | |||
? StashNode | |||
: T extends 'stash-file' | |||
? StashFileNode | |||
: T extends 'status-file' | |||
? StatusFileNode | |||
: T extends 'tag' | |||
? TagNode | |||
: T extends 'uncommitted-file' | |||
? UncommittedFileNode | |||
: ViewNode<T>; | |||
}; | |||
export function isViewNode(node: unknown): node is ViewNode; | |||
export function isViewNode<T extends keyof TreeViewNodesByType>(node: unknown, type: T): node is TreeViewNodesByType[T]; | |||
export function isViewNode<T extends keyof TreeViewNodesByType>(node: unknown, type?: T): node is ViewNode { | |||
if (node == null) return false; | |||
return node instanceof ViewNode ? type == null || node.type === type : false; | |||
} |
@ -0,0 +1,45 @@ | |||
import type { TreeViewRefFileNodeTypes, TreeViewRefNodeTypes } from '../../../constants'; | |||
import type { GitUri } from '../../../git/gitUri'; | |||
import type { GitReference, GitRevisionReference } from '../../../git/models/reference'; | |||
import { getReferenceLabel } from '../../../git/models/reference'; | |||
import type { View } from '../../viewBase'; | |||
import { ViewFileNode } from './viewFileNode'; | |||
import { ViewNode } from './viewNode'; | |||
export abstract class ViewRefNode< | |||
Type extends TreeViewRefNodeTypes = TreeViewRefNodeTypes, | |||
TView extends View = View, | |||
TReference extends GitReference = GitReference, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
constructor( | |||
type: Type, | |||
uri: GitUri, | |||
view: TView, | |||
protected override readonly parent: ViewNode, | |||
) { | |||
super(type, uri, view, parent); | |||
} | |||
abstract get ref(): TReference; | |||
get repoPath(): string { | |||
return this.uri.repoPath!; | |||
} | |||
override toString(): string { | |||
return `${super.toString()}:${getReferenceLabel(this.ref, false)}`; | |||
} | |||
} | |||
export abstract class ViewRefFileNode< | |||
Type extends TreeViewRefFileNodeTypes = TreeViewRefFileNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> extends ViewFileNode<Type, TView, State> { | |||
abstract get ref(): GitRevisionReference; | |||
override toString(): string { | |||
return `${super.toString()}:${this.file.path}`; | |||
} | |||
} |
@ -1,920 +0,0 @@ | |||
import type { Command, Event, TreeViewVisibilityChangeEvent } from 'vscode'; | |||
import { Disposable, MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||
import type { | |||
TreeViewFileNodeTypes, | |||
TreeViewNodeTypes, | |||
TreeViewRefFileNodeTypes, | |||
TreeViewRefNodeTypes, | |||
TreeViewSubscribableNodeTypes, | |||
} from '../../constants'; | |||
import { GlyphChars } from '../../constants'; | |||
import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; | |||
import type { GitUri } from '../../git/gitUri'; | |||
import { unknownGitUri } from '../../git/gitUri'; | |||
import type { GitBranch } from '../../git/models/branch'; | |||
import type { GitCommit } from '../../git/models/commit'; | |||
import type { GitContributor } from '../../git/models/contributor'; | |||
import type { GitFile } from '../../git/models/file'; | |||
import type { GitReference, GitRevisionReference } from '../../git/models/reference'; | |||
import { getReferenceLabel } from '../../git/models/reference'; | |||
import type { GitReflogRecord } from '../../git/models/reflog'; | |||
import { GitRemote } from '../../git/models/remote'; | |||
import type { RepositoryChangeEvent } from '../../git/models/repository'; | |||
import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; | |||
import type { GitTag } from '../../git/models/tag'; | |||
import type { GitWorktree } from '../../git/models/worktree'; | |||
import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; | |||
import type { | |||
CloudWorkspace, | |||
CloudWorkspaceRepositoryDescriptor, | |||
LocalWorkspace, | |||
LocalWorkspaceRepositoryDescriptor, | |||
} from '../../plus/workspaces/models'; | |||
import { gate } from '../../system/decorators/gate'; | |||
import { debug, log, logName } from '../../system/decorators/log'; | |||
import { weakEvent } from '../../system/event'; | |||
import { is as isA, szudzikPairing } from '../../system/function'; | |||
import { getLoggableName } from '../../system/logger'; | |||
import { pad } from '../../system/string'; | |||
import type { View } from '../viewBase'; | |||
import { disposeChildren } from '../viewBase'; | |||
import type { BranchNode } from './branchNode'; | |||
import type { BranchTrackingStatus } from './branchTrackingStatusNode'; | |||
import type { CommitFileNode } from './commitFileNode'; | |||
import type { CommitNode } from './commitNode'; | |||
import type { CompareBranchNode } from './compareBranchNode'; | |||
import type { CompareResultsNode } from './compareResultsNode'; | |||
import type { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; | |||
import type { FolderNode } from './folderNode'; | |||
import type { LineHistoryTrackerNode } from './lineHistoryTrackerNode'; | |||
import type { MergeConflictFileNode } from './mergeConflictFileNode'; | |||
import type { RepositoryNode } from './repositoryNode'; | |||
import type { ResultsCommitsNode } from './resultsCommitsNode'; | |||
import type { ResultsFileNode } from './resultsFileNode'; | |||
import type { StashFileNode } from './stashFileNode'; | |||
import type { StashNode } from './stashNode'; | |||
import type { StatusFileNode } from './statusFileNode'; | |||
import type { TagNode } from './tagNode'; | |||
import type { UncommittedFileNode } from './UncommittedFileNode'; | |||
export const enum ContextValues { | |||
ActiveFileHistory = 'gitlens:history:active:file', | |||
ActiveLineHistory = 'gitlens:history:active:line', | |||
AutolinkedItems = 'gitlens:autolinked:items', | |||
AutolinkedIssue = 'gitlens:autolinked:issue', | |||
AutolinkedItem = 'gitlens:autolinked:item', | |||
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', | |||
MessageSignIn = 'gitlens:message:signin', | |||
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', | |||
UncommittedFiles = 'gitlens:uncommitted:files', | |||
Workspace = 'gitlens:workspace', | |||
WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', | |||
Workspaces = 'gitlens:workspaces', | |||
Worktree = 'gitlens:worktree', | |||
Worktrees = 'gitlens:worktrees', | |||
} | |||
export interface AmbientContext { | |||
readonly autolinksId?: string; | |||
readonly branch?: GitBranch; | |||
readonly branchStatus?: BranchTrackingStatus; | |||
readonly branchStatusUpstreamType?: 'ahead' | 'behind' | 'same' | 'none'; | |||
readonly commit?: GitCommit; | |||
readonly comparisonId?: string; | |||
readonly comparisonFiltered?: boolean; | |||
readonly contributor?: GitContributor; | |||
readonly file?: GitFile; | |||
readonly reflog?: GitReflogRecord; | |||
readonly remote?: GitRemote; | |||
readonly repository?: Repository; | |||
readonly root?: boolean; | |||
readonly searchId?: string; | |||
readonly status?: 'merging' | 'rebasing'; | |||
readonly storedComparisonId?: string; | |||
readonly tag?: GitTag; | |||
readonly workspace?: CloudWorkspace | LocalWorkspace; | |||
readonly wsRepositoryDescriptor?: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; | |||
readonly worktree?: GitWorktree; | |||
} | |||
export function getViewNodeId(type: string, context: AmbientContext): string { | |||
let uniqueness = ''; | |||
if (context.root) { | |||
uniqueness += '/root'; | |||
} | |||
if (context.workspace != null) { | |||
uniqueness += `/ws/${context.workspace.id}`; | |||
} | |||
if (context.wsRepositoryDescriptor != null) { | |||
uniqueness += `/wsrepo/${context.wsRepositoryDescriptor.id}`; | |||
} | |||
if (context.repository != null) { | |||
uniqueness += `/repo/${context.repository.id}`; | |||
} | |||
if (context.worktree != null) { | |||
uniqueness += `/worktree/${context.worktree.uri.path}`; | |||
} | |||
if (context.remote != null) { | |||
uniqueness += `/remote/${context.remote.name}`; | |||
} | |||
if (context.tag != null) { | |||
uniqueness += `/tag/${context.tag.id}`; | |||
} | |||
if (context.branch != null) { | |||
uniqueness += `/branch/${context.branch.id}`; | |||
} | |||
if (context.branchStatus != null) { | |||
uniqueness += `/branch-status/${context.branchStatus.upstream ?? '-'}`; | |||
} | |||
if (context.branchStatusUpstreamType != null) { | |||
uniqueness += `/branch-status-direction/${context.branchStatusUpstreamType}`; | |||
} | |||
if (context.status != null) { | |||
uniqueness += `/status/${context.status}`; | |||
} | |||
if (context.reflog != null) { | |||
uniqueness += `/reflog/${context.reflog.sha}+${context.reflog.selector}+${context.reflog.command}+${ | |||
context.reflog.commandArgs ?? '' | |||
}+${context.reflog.date.getTime()}`; | |||
} | |||
if (context.contributor != null) { | |||
uniqueness += `/contributor/${ | |||
context.contributor.id ?? | |||
`${context.contributor.username}+${context.contributor.email}+${context.contributor.name}` | |||
}`; | |||
} | |||
if (context.autolinksId != null) { | |||
uniqueness += `/autolinks/${context.autolinksId}`; | |||
} | |||
if (context.comparisonId != null) { | |||
uniqueness += `/comparison/${context.comparisonId}`; | |||
} | |||
if (context.searchId != null) { | |||
uniqueness += `/search/${context.searchId}`; | |||
} | |||
if (context.commit != null) { | |||
uniqueness += `/commit/${context.commit.sha}`; | |||
} | |||
if (context.file != null) { | |||
uniqueness += `/file/${context.file.path}+${context.file.status}`; | |||
} | |||
return `gitlens://viewnode/${type}${uniqueness}`; | |||
} | |||
@logName<ViewNode>((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) | |||
export abstract class ViewNode< | |||
Type extends TreeViewNodeTypes = TreeViewNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> implements Disposable | |||
{ | |||
is<T extends keyof TreeViewNodesByType>(type: T): this is TreeViewNodesByType[T] { | |||
return this.type === (type as unknown as Type); | |||
} | |||
protected _uniqueId!: string; | |||
protected splatted = false; | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// readonly uuid = uuid(); | |||
constructor( | |||
public readonly type: Type, | |||
// public readonly id: string | undefined, | |||
uri: GitUri, | |||
public readonly view: TView, | |||
protected parent?: ViewNode, | |||
) { | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// queueMicrotask(() => this.view.registerNode(this)); | |||
this._uri = uri; | |||
} | |||
protected _disposed = false; | |||
@debug() | |||
dispose() { | |||
this._disposed = true; | |||
// NOTE: @eamodio uncomment to track node leaks | |||
// this.view.unregisterNode(this); | |||
} | |||
get id(): string | undefined { | |||
return this._uniqueId; | |||
} | |||
private _context: AmbientContext | undefined; | |||
protected get context(): AmbientContext { | |||
return this._context ?? this.parent?.context ?? {}; | |||
} | |||
protected updateContext(context: AmbientContext, reset: boolean = false) { | |||
this._context = this.getNewContext(context, reset); | |||
} | |||
protected getNewContext(context: AmbientContext, reset: boolean = false) { | |||
return { ...(reset ? this.parent?.context : this.context), ...context }; | |||
} | |||
toClipboard?(): string; | |||
toString(): string { | |||
const id = this.id; | |||
return `${getLoggableName(this)}${id != null ? `(${id})` : ''}`; | |||
} | |||
protected _uri: GitUri; | |||
get uri(): GitUri { | |||
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>; | |||
resolveTreeItem?(item: TreeItem): TreeItem | Promise<TreeItem>; | |||
getCommand(): Command | undefined { | |||
return undefined; | |||
} | |||
refresh?(reset?: boolean): boolean | void | Promise<void> | Promise<boolean>; | |||
@gate<ViewNode['triggerChange']>((reset, force, avoidSelf) => `${reset}|${force}|${avoidSelf?.toString()}`) | |||
@debug() | |||
triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise<void> { | |||
if (this._disposed) return Promise.resolve(); | |||
// 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>; | |||
deleteState<T extends StateKey<State> = StateKey<State>>(key?: T): void { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to delete state'); | |||
} | |||
this.view.nodeState.deleteState(this.id, key as string); | |||
} | |||
getState<T extends StateKey<State> = StateKey<State>>(key: T): StateValue<State, T> | undefined { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to get state'); | |||
} | |||
return this.view.nodeState.getState(this.id, key as string); | |||
} | |||
storeState<T extends StateKey<State> = StateKey<State>>( | |||
key: T, | |||
value: StateValue<State, T>, | |||
sticky?: boolean, | |||
): void { | |||
if (this.id == null) { | |||
debugger; | |||
throw new Error('Id is required to store state'); | |||
} | |||
this.view.nodeState.storeState(this.id, key as string, value, sticky); | |||
} | |||
} | |||
type StateKey<T> = keyof T; | |||
type StateValue<T, P extends StateKey<T>> = P extends keyof T ? T[P] : never; | |||
export abstract class CacheableChildrenViewNode< | |||
Type extends TreeViewNodeTypes = TreeViewNodeTypes, | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
private _children: TChild[] | undefined; | |||
protected get children(): TChild[] | undefined { | |||
return this._children; | |||
} | |||
protected set children(value: TChild[] | undefined) { | |||
if (this._children === value) return; | |||
disposeChildren(this._children, value); | |||
this._children = value; | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
this.children = undefined; | |||
} | |||
@debug() | |||
override refresh(reset: boolean = false) { | |||
if (reset) { | |||
this.children = undefined; | |||
} | |||
} | |||
} | |||
export abstract class ViewFileNode< | |||
Type extends TreeViewFileNodeTypes = TreeViewFileNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
constructor( | |||
type: Type, | |||
uri: GitUri, | |||
view: TView, | |||
public override parent: ViewNode, | |||
public readonly file: GitFile, | |||
) { | |||
super(type, uri, view, parent); | |||
} | |||
get repoPath(): string { | |||
return this.uri.repoPath!; | |||
} | |||
override toString(): string { | |||
return `${super.toString()}:${this.file.path}`; | |||
} | |||
} | |||
export abstract class ViewRefNode< | |||
Type extends TreeViewRefNodeTypes = TreeViewRefNodeTypes, | |||
TView extends View = View, | |||
TReference extends GitReference = GitReference, | |||
State extends object = any, | |||
> extends ViewNode<Type, TView, State> { | |||
constructor( | |||
type: Type, | |||
uri: GitUri, | |||
view: TView, | |||
protected override readonly parent: ViewNode, | |||
) { | |||
super(type, uri, view, parent); | |||
} | |||
abstract get ref(): TReference; | |||
get repoPath(): string { | |||
return this.uri.repoPath!; | |||
} | |||
override toString(): string { | |||
return `${super.toString()}:${getReferenceLabel(this.ref, false)}`; | |||
} | |||
} | |||
export abstract class ViewRefFileNode< | |||
Type extends TreeViewRefFileNodeTypes = TreeViewRefFileNodeTypes, | |||
TView extends View = View, | |||
State extends object = any, | |||
> extends ViewFileNode<Type, TView, State> { | |||
abstract get ref(): GitRevisionReference; | |||
override toString(): string { | |||
return `${super.toString()}:${this.file.path}`; | |||
} | |||
} | |||
export interface PageableViewNode extends ViewNode { | |||
readonly id: string; | |||
limit?: number; | |||
readonly hasMore: boolean; | |||
loadMore(limit?: number | { until?: string | undefined }, context?: Record<string, unknown>): Promise<void>; | |||
} | |||
export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { | |||
return isA<ViewNode & PageableViewNode>(node, 'loadMore'); | |||
} | |||
export abstract class SubscribeableViewNode< | |||
Type extends TreeViewSubscribableNodeTypes = TreeViewSubscribableNodeTypes, | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
State extends object = any, | |||
> extends CacheableChildrenViewNode<Type, TView, TChild, State> { | |||
protected disposable: Disposable; | |||
protected subscription: Promise<Disposable | undefined> | undefined; | |||
protected loaded: boolean = false; | |||
constructor(type: Type, uri: GitUri, view: TView, parent?: ViewNode) { | |||
super(type, uri, view, parent); | |||
const disposables = [ | |||
weakEvent(this.view.onDidChangeVisibility, this.onVisibilityChanged, this), | |||
// weak(this.view.onDidChangeNodeCollapsibleState, this.onNodeCollapsibleStateChanged, this), | |||
]; | |||
if (canAutoRefreshView(this.view)) { | |||
disposables.push(weakEvent(this.view.onDidChangeAutoRefresh, this.onAutoRefreshChanged, this)); | |||
} | |||
const getTreeItem = this.getTreeItem; | |||
this.getTreeItem = function (this: SubscribeableViewNode<Type, TView>) { | |||
this.loaded = true; | |||
void this.ensureSubscription(); | |||
return getTreeItem.apply(this); | |||
}; | |||
const getChildren = this.getChildren; | |||
this.getChildren = function (this: SubscribeableViewNode<Type, TView>) { | |||
this.loaded = true; | |||
void this.ensureSubscription(); | |||
return getChildren.apply(this); | |||
}; | |||
this.disposable = Disposable.from(...disposables); | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
void this.unsubscribe(); | |||
this.disposable?.dispose(); | |||
} | |||
@gate<ViewNode['triggerChange']>((reset, force) => `${reset}|${force}`) | |||
@debug() | |||
override async triggerChange(reset: boolean = false, force: boolean = false): Promise<void> { | |||
if (!this.loaded || this._disposed) return; | |||
if (reset && !this.view.visible) { | |||
this._pendingReset = reset; | |||
} | |||
await super.triggerChange(reset, force); | |||
} | |||
private _canSubscribe: boolean = true; | |||
protected get canSubscribe(): boolean { | |||
return this._canSubscribe && !this._disposed; | |||
} | |||
protected set canSubscribe(value: boolean) { | |||
if (this._canSubscribe === value) return; | |||
this._canSubscribe = value; | |||
void this.ensureSubscription(); | |||
if (value) { | |||
void this.triggerChange(); | |||
} | |||
} | |||
private _etag: number | undefined; | |||
protected abstract etag(): number; | |||
private _pendingReset: boolean = false; | |||
private get requiresResetOnVisible(): boolean { | |||
let reset = this._pendingReset; | |||
this._pendingReset = false; | |||
const etag = this.etag(); | |||
if (etag !== this._etag) { | |||
this._etag = etag; | |||
reset = true; | |||
} | |||
return reset; | |||
} | |||
protected abstract subscribe(): Disposable | undefined | Promise<Disposable | undefined>; | |||
@debug() | |||
protected async unsubscribe(): Promise<void> { | |||
this._etag = this.etag(); | |||
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 || (canAutoRefreshView(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()); | |||
void (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<'repo-folder', TView> { | |||
protected override splatted = true; | |||
constructor( | |||
uri: GitUri, | |||
view: TView, | |||
protected override readonly parent: ViewNode, | |||
public readonly repo: Repository, | |||
splatted: boolean, | |||
private readonly options?: { showBranchAndLastFetched?: boolean }, | |||
) { | |||
super('repo-folder', uri, view, parent); | |||
this.updateContext({ repository: this.repo }); | |||
this._uniqueId = getViewNodeId(this.type, this.context); | |||
this.splatted = splatted; | |||
} | |||
private _child: TChild | undefined; | |||
protected get child(): TChild | undefined { | |||
return this._child; | |||
} | |||
protected set child(value: TChild | undefined) { | |||
if (this._child === value) return; | |||
this._child?.dispose(); | |||
this._child = value; | |||
} | |||
@debug() | |||
override dispose() { | |||
super.dispose(); | |||
this.child = undefined; | |||
} | |||
override get id(): string { | |||
return this._uniqueId; | |||
} | |||
override toClipboard(): string { | |||
return this.repo.path; | |||
} | |||
get repoPath(): string { | |||
return this.repo.path; | |||
} | |||
async getTreeItem(): Promise<TreeItem> { | |||
this.splatted = false; | |||
const branch = await this.repo.getBranch(); | |||
const ahead = (branch?.state.ahead ?? 0) > 0; | |||
const behind = (branch?.state.behind ?? 0) > 0; | |||
const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); | |||
const item = new TreeItem( | |||
this.repo.formattedName ?? this.uri.repoPath ?? '', | |||
expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, | |||
); | |||
item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; | |||
if (ahead) { | |||
item.contextValue += '+ahead'; | |||
} | |||
if (behind) { | |||
item.contextValue += '+behind'; | |||
} | |||
if (this.view.type === 'commits' && this.view.state.filterCommits.get(this.repo.id)?.length) { | |||
item.contextValue += '+filtered'; | |||
} | |||
if (branch != null && this.options?.showBranchAndLastFetched) { | |||
const lastFetched = (await this.repo.getLastFetched()) ?? 0; | |||
const status = branch.getTrackingStatus(); | |||
item.description = `${status ? `${status}${pad(GlyphChars.Dot, 1, 1)}` : ''}${branch.name}${ | |||
lastFetched | |||
? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` | |||
: '' | |||
}`; | |||
let providerName; | |||
if (branch.upstream != null) { | |||
const providers = GitRemote.getHighlanderProviders( | |||
await this.view.container.git.getRemotesWithProviders(branch.repoPath), | |||
); | |||
providerName = providers?.length ? providers[0].name : undefined; | |||
} else { | |||
const remote = await branch.getRemote(); | |||
providerName = remote?.provider?.name; | |||
} | |||
item.tooltip = new MarkdownString( | |||
`${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ | |||
lastFetched | |||
? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( | |||
lastFetched, | |||
false, | |||
)}` | |||
: '' | |||
}${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ | |||
branch.name | |||
}${ | |||
branch.upstream != null | |||
? ` is ${branch.getTrackingStatus({ | |||
empty: branch.upstream.missing | |||
? `missing upstream $(git-branch) ${branch.upstream.name}` | |||
: `up to date with $(git-branch) ${branch.upstream.name}${ | |||
providerName ? ` on ${providerName}` : '' | |||
}`, | |||
expand: true, | |||
icons: true, | |||
separator: ', ', | |||
suffix: ` $(git-branch) ${branch.upstream.name}${ | |||
providerName ? ` on ${providerName}` : '' | |||
}`, | |||
})}` | |||
: `hasn't been published to ${providerName ?? 'a remote'}` | |||
}`, | |||
true, | |||
); | |||
} else { | |||
item.tooltip = `${ | |||
this.repo.formattedName ? `${this.repo.formattedName}\n${this.uri.repoPath}` : this.uri.repoPath ?? '' | |||
}`; | |||
} | |||
return item; | |||
} | |||
override async getSplattedChild() { | |||
if (this.child == null) { | |||
await this.getChildren(); | |||
} | |||
return this.child; | |||
} | |||
@gate() | |||
@debug() | |||
override async refresh(reset: boolean = false) { | |||
super.refresh(reset); | |||
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 weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this); | |||
} | |||
protected override etag(): number { | |||
return this.repo.etag; | |||
} | |||
protected abstract changed(e: RepositoryChangeEvent): boolean; | |||
@debug<RepositoryFolderNode['onRepositoryChanged']>({ args: { 0: e => e.toString() } }) | |||
private onRepositoryChanged(e: RepositoryChangeEvent) { | |||
if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { | |||
this.dispose(); | |||
void this.parent?.triggerChange(true); | |||
return; | |||
} | |||
if ( | |||
e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any) || | |||
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); | |||
} | |||
} | |||
} | |||
export abstract class RepositoriesSubscribeableNode< | |||
TView extends View = View, | |||
TChild extends ViewNode = ViewNode, | |||
> extends SubscribeableViewNode<'repositories', TView, TChild> { | |||
protected override splatted = true; | |||
constructor(view: TView) { | |||
super('repositories', unknownGitUri, view); | |||
} | |||
override async getSplattedChild() { | |||
if (this.children == null) { | |||
await this.getChildren(); | |||
} | |||
return this.children?.length === 1 ? this.children[0] : undefined; | |||
} | |||
protected override etag(): number { | |||
return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); | |||
} | |||
@debug() | |||
protected subscribe(): Disposable | Promise<Disposable> { | |||
return Disposable.from( | |||
weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), | |||
weakEvent(this.view.container.subscription.onDidChange, this.onSubscriptionChanged, this), | |||
); | |||
} | |||
private onRepositoriesChanged(_e: RepositoriesChangeEvent) { | |||
void this.triggerChange(true); | |||
} | |||
private onSubscriptionChanged(e: SubscriptionChangeEvent) { | |||
if (e.current.plan !== e.previous.plan) { | |||
void this.triggerChange(true); | |||
} | |||
} | |||
} | |||
interface AutoRefreshableView { | |||
autoRefresh: boolean; | |||
onDidChangeAutoRefresh: Event<void>; | |||
} | |||
export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { | |||
return isA<View & AutoRefreshableView>(view, 'onDidChangeAutoRefresh'); | |||
} | |||
export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise<void> } { | |||
return typeof (node as ViewNode & { edit(): void | Promise<void> }).edit === 'function'; | |||
} | |||
export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { | |||
return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; | |||
} | |||
export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { | |||
return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; | |||
} | |||
export function getNodeRepoPath(node?: ViewNode): string | undefined { | |||
return canGetNodeRepoPath(node) ? node.repoPath : undefined; | |||
} | |||
type TreeViewNodesByType = { | |||
[T in TreeViewNodeTypes]: T extends 'branch' | |||
? BranchNode | |||
: T extends 'commit' | |||
? CommitNode | |||
: T extends 'commit-file' | |||
? CommitFileNode | |||
: T extends 'compare-branch' | |||
? CompareBranchNode | |||
: T extends 'compare-results' | |||
? CompareResultsNode | |||
: T extends 'conflict-file' | |||
? MergeConflictFileNode | |||
: T extends 'file-commit' | |||
? FileRevisionAsCommitNode | |||
: T extends 'folder' | |||
? FolderNode | |||
: T extends 'line-history-tracker' | |||
? LineHistoryTrackerNode | |||
: T extends 'repository' | |||
? RepositoryNode | |||
: T extends 'repo-folder' | |||
? RepositoryFolderNode | |||
: T extends 'results-commits' | |||
? ResultsCommitsNode | |||
: T extends 'results-file' | |||
? ResultsFileNode | |||
: T extends 'stash' | |||
? StashNode | |||
: T extends 'stash-file' | |||
? StashFileNode | |||
: T extends 'status-file' | |||
? StatusFileNode | |||
: T extends 'tag' | |||
? TagNode | |||
: T extends 'uncommitted-file' | |||
? UncommittedFileNode | |||
: ViewNode<T>; | |||
}; | |||
export function isViewNode(node: unknown): node is ViewNode; | |||
export function isViewNode<T extends keyof TreeViewNodesByType>(node: unknown, type: T): node is TreeViewNodesByType[T]; | |||
export function isViewNode<T extends keyof TreeViewNodesByType>(node: unknown, type?: T): node is ViewNode { | |||
if (node == null) return false; | |||
return node instanceof ViewNode ? type == null || node.type === type : false; | |||
} | |||
export function isViewFileNode(node: unknown): node is ViewFileNode { | |||
return node instanceof ViewFileNode; | |||
} |