Path: blob/main/extensions/git/src/artifactProvider.ts
4772 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode';6import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util';7import { Repository } from './repository';8import { Commit, Ref, RefType } from './api/git';9import { OperationKind } from './operation';1011/**12* Sorts refs like a directory tree: refs with more path segments (directories) appear first13* and are sorted alphabetically, while refs at the same level (files) maintain insertion order.14* Refs without '/' maintain their insertion order and appear after refs with '/'.15*/16function sortRefByName(refA: Ref, refB: Ref): number {17const nameA = refA.name ?? '';18const nameB = refB.name ?? '';1920const lastSlashA = nameA.lastIndexOf('/');21const lastSlashB = nameB.lastIndexOf('/');2223// Neither ref has a slash, maintain insertion order24if (lastSlashA === -1 && lastSlashB === -1) {25return 0;26}2728// Ref with a slash comes first29if (lastSlashA !== -1 && lastSlashB === -1) {30return -1;31} else if (lastSlashA === -1 && lastSlashB !== -1) {32return 1;33}3435// Both have slashes36// Get directory segments37const segmentsA = nameA.substring(0, lastSlashA).split('/');38const segmentsB = nameB.substring(0, lastSlashB).split('/');3940// Compare directory segments41for (let index = 0; index < Math.min(segmentsA.length, segmentsB.length); index++) {42const result = segmentsA[index].localeCompare(segmentsB[index]);43if (result !== 0) {44return result;45}46}4748// Directory with more segments comes first49if (segmentsA.length !== segmentsB.length) {50return segmentsB.length - segmentsA.length;51}5253// Insertion order54return 0;55}5657function sortByCommitDateDesc(a: { commitDetails?: Commit }, b: { commitDetails?: Commit }): number {58const aCommitDate = a.commitDetails?.commitDate?.getTime() ?? 0;59const bCommitDate = b.commitDetails?.commitDate?.getTime() ?? 0;6061return bCommitDate - aCommitDate;62}6364export class GitArtifactProvider implements SourceControlArtifactProvider, IDisposable {65private readonly _onDidChangeArtifacts = new EventEmitter<string[]>();66readonly onDidChangeArtifacts: Event<string[]> = this._onDidChangeArtifacts.event;6768private readonly _groups: SourceControlArtifactGroup[];69private readonly _disposables: Disposable[] = [];7071constructor(72private readonly repository: Repository,73private readonly logger: LogOutputChannel74) {75this._groups = [76{ id: 'branches', name: l10n.t('Branches'), icon: new ThemeIcon('git-branch'), supportsFolders: true },77{ id: 'stashes', name: l10n.t('Stashes'), icon: new ThemeIcon('git-stash'), supportsFolders: false },78{ id: 'tags', name: l10n.t('Tags'), icon: new ThemeIcon('tag'), supportsFolders: true },79{ id: 'worktrees', name: l10n.t('Worktrees'), icon: new ThemeIcon('worktree'), supportsFolders: false }80];8182this._disposables.push(this._onDidChangeArtifacts);83this._disposables.push(repository.historyProvider.onDidChangeHistoryItemRefs(e => {84const groups = new Set<string>();85for (const ref of e.added.concat(e.modified).concat(e.removed)) {86if (ref.id.startsWith('refs/heads/')) {87groups.add('branches');88} else if (ref.id.startsWith('refs/tags/')) {89groups.add('tags');90}91}9293this._onDidChangeArtifacts.fire(Array.from(groups));94}));9596const onDidRunWriteOperation = filterEvent(97repository.onDidRunOperation, e => !e.operation.readOnly);9899this._disposables.push(onDidRunWriteOperation(result => {100if (result.operation.kind === OperationKind.Stash) {101this._onDidChangeArtifacts.fire(['stashes']);102} else if (result.operation.kind === OperationKind.Worktree) {103this._onDidChangeArtifacts.fire(['worktrees']);104}105}));106}107108provideArtifactGroups(): SourceControlArtifactGroup[] {109return this._groups;110}111112async provideArtifacts(group: string): Promise<SourceControlArtifact[]> {113const config = workspace.getConfiguration('git', Uri.file(this.repository.root));114const shortCommitLength = config.get<number>('commitShortHashLength', 7);115116try {117if (group === 'branches') {118const refs = await this.repository119.getRefs({ pattern: 'refs/heads', includeCommitDetails: true, sort: 'creatordate' });120121return refs.sort(sortRefByName).map(r => ({122id: `refs/heads/${r.name}`,123name: r.name ?? r.commit ?? '',124description: coalesce([125r.commit?.substring(0, shortCommitLength),126r.commitDetails?.message.split('\n')[0]127]).join(' \u2022 '),128icon: this.repository.HEAD?.type === RefType.Head && r.name === this.repository.HEAD?.name129? new ThemeIcon('target')130: new ThemeIcon('git-branch'),131timestamp: r.commitDetails?.commitDate?.getTime()132}));133} else if (group === 'tags') {134const refs = await this.repository135.getRefs({ pattern: 'refs/tags', includeCommitDetails: true, sort: 'creatordate' });136137return refs.sort(sortRefByName).map(r => ({138id: `refs/tags/${r.name}`,139name: r.name ?? r.commit ?? '',140description: coalesce([141r.commit?.substring(0, shortCommitLength),142r.commitDetails?.message.split('\n')[0]143]).join(' \u2022 '),144icon: this.repository.HEAD?.type === RefType.Tag && r.name === this.repository.HEAD?.name145? new ThemeIcon('target')146: new ThemeIcon('tag'),147timestamp: r.commitDetails?.commitDate?.getTime()148}));149} else if (group === 'stashes') {150const stashes = await this.repository.getStashes();151152return stashes.map(s => ({153id: `stash@{${s.index}}`,154name: s.description,155description: s.branchName,156icon: new ThemeIcon('git-stash'),157timestamp: s.commitDate?.getTime(),158command: {159title: l10n.t('View Stash'),160command: 'git.repositories.stashView'161} satisfies Command162}));163} else if (group === 'worktrees') {164const worktrees = await this.repository.getWorktreeDetails();165166return worktrees.sort(sortByCommitDateDesc).map(w => ({167id: w.path,168name: w.name,169description: coalesce([170w.detached ? l10n.t('detached') : w.ref.substring(11),171w.commitDetails?.hash.substring(0, shortCommitLength),172w.commitDetails?.message.split('\n')[0]173]).join(' \u2022 '),174icon: isCopilotWorktree(w.path)175? new ThemeIcon('chat-sparkle')176: new ThemeIcon('worktree'),177timestamp: w.commitDetails?.commitDate?.getTime(),178}));179}180} catch (err) {181this.logger.error(`[GitArtifactProvider][provideArtifacts] Error while providing artifacts for group '${group}': `, err);182return [];183}184185return [];186}187188dispose(): void {189dispose(this._disposables);190}191}192193194