Files
vscode/extensions/git/src/artifactProvider.ts
2026-01-13 15:38:32 +00:00

197 lines
6.9 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode';
import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util';
import { Repository } from './repository';
import { Ref, RefType, Worktree } from './api/git';
import { OperationKind } from './operation';
/**
* Sorts refs like a directory tree: refs with more path segments (directories) appear first
* and are sorted alphabetically, while refs at the same level (files) maintain insertion order.
* Refs without '/' maintain their insertion order and appear after refs with '/'.
*/
function sortRefByName(refA: Ref, refB: Ref): number {
const nameA = refA.name ?? '';
const nameB = refB.name ?? '';
const lastSlashA = nameA.lastIndexOf('/');
const lastSlashB = nameB.lastIndexOf('/');
// Neither ref has a slash, maintain insertion order
if (lastSlashA === -1 && lastSlashB === -1) {
return 0;
}
// Ref with a slash comes first
if (lastSlashA !== -1 && lastSlashB === -1) {
return -1;
} else if (lastSlashA === -1 && lastSlashB !== -1) {
return 1;
}
// Both have slashes
// Get directory segments
const segmentsA = nameA.substring(0, lastSlashA).split('/');
const segmentsB = nameB.substring(0, lastSlashB).split('/');
// Compare directory segments
for (let index = 0; index < Math.min(segmentsA.length, segmentsB.length); index++) {
const result = segmentsA[index].localeCompare(segmentsB[index]);
if (result !== 0) {
return result;
}
}
// Directory with more segments comes first
if (segmentsA.length !== segmentsB.length) {
return segmentsB.length - segmentsA.length;
}
// Insertion order
return 0;
}
function sortByWorktreeTypeAndNameAsc(a: Worktree, b: Worktree): number {
if (a.main && !b.main) {
return -1;
} else if (!a.main && b.main) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
}
export class GitArtifactProvider implements SourceControlArtifactProvider, IDisposable {
private readonly _onDidChangeArtifacts = new EventEmitter<string[]>();
readonly onDidChangeArtifacts: Event<string[]> = this._onDidChangeArtifacts.event;
private readonly _groups: SourceControlArtifactGroup[];
private readonly _disposables: Disposable[] = [];
constructor(
private readonly repository: Repository,
private readonly logger: LogOutputChannel
) {
this._groups = [
{ id: 'branches', name: l10n.t('Branches'), icon: new ThemeIcon('git-branch'), supportsFolders: true },
{ id: 'stashes', name: l10n.t('Stashes'), icon: new ThemeIcon('git-stash'), supportsFolders: false },
{ id: 'tags', name: l10n.t('Tags'), icon: new ThemeIcon('tag'), supportsFolders: true },
{ id: 'worktrees', name: l10n.t('Worktrees'), icon: new ThemeIcon('worktree'), supportsFolders: false }
];
this._disposables.push(this._onDidChangeArtifacts);
this._disposables.push(repository.historyProvider.onDidChangeHistoryItemRefs(e => {
const groups = new Set<string>();
for (const ref of e.added.concat(e.modified).concat(e.removed)) {
if (ref.id.startsWith('refs/heads/')) {
groups.add('branches');
} else if (ref.id.startsWith('refs/tags/')) {
groups.add('tags');
}
}
this._onDidChangeArtifacts.fire(Array.from(groups));
}));
const onDidRunWriteOperation = filterEvent(
repository.onDidRunOperation, e => !e.operation.readOnly);
this._disposables.push(onDidRunWriteOperation(result => {
if (result.operation.kind === OperationKind.Stash) {
this._onDidChangeArtifacts.fire(['stashes']);
} else if (result.operation.kind === OperationKind.Worktree) {
this._onDidChangeArtifacts.fire(['worktrees']);
}
}));
}
provideArtifactGroups(): SourceControlArtifactGroup[] {
return this._groups;
}
async provideArtifacts(group: string): Promise<SourceControlArtifact[]> {
const config = workspace.getConfiguration('git', Uri.file(this.repository.root));
const shortCommitLength = config.get<number>('commitShortHashLength', 7);
try {
if (group === 'branches') {
const refs = await this.repository
.getRefs({ pattern: 'refs/heads', includeCommitDetails: true, sort: 'creatordate' });
return refs.sort(sortRefByName).map(r => ({
id: `refs/heads/${r.name}`,
name: r.name ?? r.commit ?? '',
description: coalesce([
r.commit?.substring(0, shortCommitLength),
r.commitDetails?.message.split('\n')[0]
]).join(' \u2022 '),
icon: this.repository.HEAD?.type === RefType.Head && r.name === this.repository.HEAD?.name
? new ThemeIcon('target')
: new ThemeIcon('git-branch'),
timestamp: r.commitDetails?.commitDate?.getTime()
}));
} else if (group === 'tags') {
const refs = await this.repository
.getRefs({ pattern: 'refs/tags', includeCommitDetails: true, sort: 'creatordate' });
return refs.sort(sortRefByName).map(r => ({
id: `refs/tags/${r.name}`,
name: r.name ?? r.commit ?? '',
description: coalesce([
r.commit?.substring(0, shortCommitLength),
r.commitDetails?.message.split('\n')[0]
]).join(' \u2022 '),
icon: this.repository.HEAD?.type === RefType.Tag && r.name === this.repository.HEAD?.name
? new ThemeIcon('target')
: new ThemeIcon('tag'),
timestamp: r.commitDetails?.commitDate?.getTime()
}));
} else if (group === 'stashes') {
const stashes = await this.repository.getStashes();
return stashes.map(s => ({
id: `stash@{${s.index}}`,
name: s.description,
description: s.branchName,
icon: new ThemeIcon('git-stash'),
timestamp: s.commitDate?.getTime(),
command: {
title: l10n.t('View Stash'),
command: 'git.repositories.stashView'
} satisfies Command
}));
} else if (group === 'worktrees') {
const worktrees = await this.repository.getWorktreeDetails();
return worktrees.sort(sortByWorktreeTypeAndNameAsc).map(w => ({
id: w.path,
name: w.name,
description: coalesce([
w.detached ? l10n.t('detached') : w.ref.substring(11),
w.commitDetails?.hash.substring(0, shortCommitLength),
w.commitDetails?.message.split('\n')[0]
]).join(' \u2022 '),
icon: w.main
? new ThemeIcon('repo')
: isCopilotWorktree(w.path)
? new ThemeIcon('chat-sparkle')
: new ThemeIcon('worktree')
}));
}
} catch (err) {
this.logger.error(`[GitArtifactProvider][provideArtifacts] Error while providing artifacts for group '${group}': `, err);
return [];
}
return [];
}
dispose(): void {
dispose(this._disposables);
}
}