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.
 

560 lines
16 KiB

import type { ConfigurationChangeEvent, Disposable } from 'vscode';
import { TreeItem, TreeItemCollapsibleState } from 'vscode';
import type { SearchAndCompareViewConfig } from '../configuration';
import { configuration, ViewFilesLayout } from '../configuration';
import { Commands, ContextKeys } from '../constants';
import type { Container } from '../container';
import { setContext } from '../context';
import { unknownGitUri } from '../git/gitUri';
import type { GitLog } from '../git/models/log';
import { GitRevision } from '../git/models/reference';
import type { SearchPattern } from '../git/search';
import { ReferencePicker, ReferencesQuickPickIncludes } from '../quickpicks/referencePicker';
import { RepositoryPicker } from '../quickpicks/repositoryPicker';
import type { StoredNamedRef, StoredPinnedItem, StoredPinnedItems } from '../storage';
import { filterMap } from '../system/array';
import { executeCommand } from '../system/command';
import { gate } from '../system/decorators/gate';
import { debug, log } from '../system/decorators/log';
import { updateRecordValue } from '../system/object';
import { isPromise } from '../system/promise';
import { ComparePickerNode } from './nodes/comparePickerNode';
import { CompareResultsNode } from './nodes/compareResultsNode';
import { FilesQueryFilter, ResultsFilesNode } from './nodes/resultsFilesNode';
import { SearchResultsNode } from './nodes/searchResultsNode';
import { ContextValues, RepositoryFolderNode, ViewNode } from './nodes/viewNode';
import { ViewBase } from './viewBase';
import { registerViewCommand } from './viewCommands';
export class SearchAndCompareViewNode extends ViewNode<SearchAndCompareView> {
protected override splatted = true;
private comparePicker: ComparePickerNode | undefined;
constructor(view: SearchAndCompareView) {
super(unknownGitUri, view);
}
private _children: (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] | undefined;
private get children(): (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] {
if (this._children == null) {
this._children = [];
// Get pinned searches & comparisons
const pinned = this.view.getPinned();
if (pinned.length !== 0) {
this._children.push(...pinned);
}
}
return this._children;
}
getChildren(): ViewNode[] {
if (this.children.length === 0) return [];
this.view.message = undefined;
return this.children.sort((a, b) => (a.pinned ? -1 : 1) - (b.pinned ? -1 : 1) || b.order - a.order);
}
getTreeItem(): TreeItem {
this.splatted = false;
const item = new TreeItem('SearchAndCompare', TreeItemCollapsibleState.Expanded);
item.contextValue = ContextValues.SearchAndCompare;
return item;
}
addOrReplace(results: CompareResultsNode | SearchResultsNode, replace: boolean) {
if (this.children.includes(results)) return;
if (replace) {
this.clear();
}
this.children.push(results);
this.view.triggerNodeChange();
}
@log()
clear(silent: boolean = false) {
if (this.children.length === 0) return;
this.removeComparePicker(true);
const index = this._children!.findIndex(c => !c.pinned);
if (index !== -1) {
this._children!.splice(index, this._children!.length);
}
if (!silent) {
this.view.triggerNodeChange();
}
}
@log<SearchAndCompareViewNode['dismiss']>({ args: { 0: n => n.toString() } })
dismiss(node: ComparePickerNode | CompareResultsNode | SearchResultsNode) {
if (node === this.comparePicker) {
this.removeComparePicker();
return;
}
if (this.children.length === 0) return;
const index = this.children.indexOf(node);
if (index === -1) return;
this.children.splice(index, 1);
this.view.triggerNodeChange();
}
@gate()
@debug()
override async refresh() {
if (this.children.length === 0) return;
const promises: Promise<any>[] = [
...filterMap(this.children, c => {
const result = c.refresh === undefined ? false : c.refresh();
return isPromise<boolean | void>(result) ? result : undefined;
}),
];
await Promise.all(promises);
}
async compareWithSelected(repoPath?: string, ref?: string | StoredNamedRef) {
const selectedRef = this.comparePicker?.selectedRef;
if (selectedRef == null) return;
if (repoPath == null) {
repoPath = selectedRef.repoPath;
} else if (repoPath !== selectedRef.repoPath) {
// If we don't have a matching repoPath, then start over
void this.selectForCompare(repoPath, ref);
return;
}
if (ref == null) {
const pick = await ReferencePicker.show(
repoPath,
`Compare ${this.getRefName(selectedRef.ref)} with`,
'Choose a reference to compare with',
{
allowEnteringRefs: true,
picked: typeof selectedRef.ref === 'string' ? selectedRef.ref : selectedRef.ref.ref,
// checkmarks: true,
include: ReferencesQuickPickIncludes.BranchesAndTags | ReferencesQuickPickIncludes.HEAD,
sort: { branches: { current: true } },
},
);
if (pick == null) {
if (this.comparePicker != null) {
await this.view.show();
await this.view.reveal(this.comparePicker, { focus: true, select: true });
}
return;
}
ref = pick.ref;
}
this.removeComparePicker();
await this.view.compare(repoPath, selectedRef.ref, ref);
}
async selectForCompare(repoPath?: string, ref?: string | StoredNamedRef, options?: { prompt?: boolean }) {
if (repoPath == null) {
repoPath = (await RepositoryPicker.getRepositoryOrShow('Compare'))?.path;
}
if (repoPath == null) return;
this.removeComparePicker(true);
let prompt = options?.prompt ?? false;
let ref2;
if (ref == null) {
const pick = await ReferencePicker.show(repoPath, 'Compare', 'Choose a reference to compare', {
allowEnteringRefs: { ranges: true },
// checkmarks: false,
include:
ReferencesQuickPickIncludes.BranchesAndTags |
ReferencesQuickPickIncludes.HEAD |
ReferencesQuickPickIncludes.WorkingTree,
sort: { branches: { current: true }, tags: {} },
});
if (pick == null) {
await this.triggerChange();
return;
}
ref = pick.ref;
if (GitRevision.isRange(ref)) {
const range = GitRevision.splitRange(ref);
if (range != null) {
ref = range.ref1 || 'HEAD';
ref2 = range.ref2 || 'HEAD';
}
}
prompt = true;
}
this.comparePicker = new ComparePickerNode(this.view, this, {
label: this.getRefName(ref),
repoPath: repoPath,
ref: ref,
});
this.children.splice(0, 0, this.comparePicker);
void setContext(ContextKeys.ViewsCanCompare, true);
await this.triggerChange();
await this.view.reveal(this.comparePicker, { focus: false, select: true });
if (prompt) {
await this.compareWithSelected(repoPath, ref2);
}
}
private getRefName(ref: string | StoredNamedRef): string {
return typeof ref === 'string'
? GitRevision.shorten(ref, { strings: { working: 'Working Tree' } })!
: ref.label ?? GitRevision.shorten(ref.ref)!;
}
private removeComparePicker(silent: boolean = false) {
void setContext(ContextKeys.ViewsCanCompare, false);
if (this.comparePicker != null) {
const index = this.children.indexOf(this.comparePicker);
if (index !== -1) {
this.children.splice(index, 1);
if (!silent) {
void this.triggerChange();
}
}
this.comparePicker = undefined;
}
}
}
export class SearchAndCompareView extends ViewBase<SearchAndCompareViewNode, SearchAndCompareViewConfig> {
protected readonly configKey = 'searchAndCompare';
constructor(container: Container) {
super(container, 'gitlens.views.searchAndCompare', 'Search & Compare', 'searchAndCompareView');
void setContext(ContextKeys.ViewsSearchAndCompareKeepResults, this.keepResults);
}
protected getRoot() {
return new SearchAndCompareViewNode(this);
}
protected registerCommands(): Disposable[] {
void this.container.viewCommands;
return [
registerViewCommand(this.getQualifiedCommand('clear'), () => this.clear(), this),
registerViewCommand(
this.getQualifiedCommand('copy'),
() => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection),
this,
),
registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this),
registerViewCommand(
this.getQualifiedCommand('setFilesLayoutToAuto'),
() => this.setFilesLayout(ViewFilesLayout.Auto),
this,
),
registerViewCommand(
this.getQualifiedCommand('setFilesLayoutToList'),
() => this.setFilesLayout(ViewFilesLayout.List),
this,
),
registerViewCommand(
this.getQualifiedCommand('setFilesLayoutToTree'),
() => this.setFilesLayout(ViewFilesLayout.Tree),
this,
),
registerViewCommand(this.getQualifiedCommand('setKeepResultsToOn'), () => this.setKeepResults(true), this),
registerViewCommand(
this.getQualifiedCommand('setKeepResultsToOff'),
() => this.setKeepResults(false),
this,
),
registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this),
registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this),
registerViewCommand(this.getQualifiedCommand('pin'), this.pin, this),
registerViewCommand(this.getQualifiedCommand('unpin'), this.unpin, this),
registerViewCommand(this.getQualifiedCommand('swapComparison'), this.swapComparison, this),
registerViewCommand(this.getQualifiedCommand('selectForCompare'), this.selectForCompare, this),
registerViewCommand(this.getQualifiedCommand('compareWithSelected'), this.compareWithSelected, this),
registerViewCommand(
this.getQualifiedCommand('setFilesFilterOnLeft'),
n => this.setFilesFilter(n, FilesQueryFilter.Left),
this,
),
registerViewCommand(
this.getQualifiedCommand('setFilesFilterOnRight'),
n => this.setFilesFilter(n, FilesQueryFilter.Right),
this,
),
registerViewCommand(
this.getQualifiedCommand('setFilesFilterOff'),
n => this.setFilesFilter(n, undefined),
this,
),
];
}
protected override filterConfigurationChanged(e: ConfigurationChangeEvent) {
const changed = super.filterConfigurationChanged(e);
if (
!changed &&
!configuration.changed(e, 'defaultDateFormat') &&
!configuration.changed(e, 'defaultDateLocale') &&
!configuration.changed(e, 'defaultDateShortFormat') &&
!configuration.changed(e, 'defaultDateSource') &&
!configuration.changed(e, 'defaultDateStyle') &&
!configuration.changed(e, 'defaultGravatarsStyle') &&
!configuration.changed(e, 'defaultTimeFormat')
) {
return false;
}
return true;
}
get keepResults(): boolean {
return this.container.storage.getWorkspace('views:searchAndCompare:keepResults', true);
}
clear() {
this.root?.clear();
}
dismissNode(node: ViewNode) {
if (
this.root == null ||
(!(node instanceof ComparePickerNode) &&
!(node instanceof CompareResultsNode) &&
!(node instanceof SearchResultsNode)) ||
!node.canDismiss
) {
return;
}
this.root.dismiss(node);
}
compare(repoPath: string, ref1: string | StoredNamedRef, ref2: string | StoredNamedRef) {
return this.addResults(
new CompareResultsNode(
this,
this.ensureRoot(),
repoPath,
typeof ref1 === 'string' ? { ref: ref1 } : ref1,
typeof ref2 === 'string' ? { ref: ref2 } : ref2,
),
);
}
compareWithSelected(repoPath?: string, ref?: string | StoredNamedRef) {
void this.ensureRoot().compareWithSelected(repoPath, ref);
}
selectForCompare(repoPath?: string, ref?: string | StoredNamedRef, options?: { prompt?: boolean }) {
void this.ensureRoot().selectForCompare(repoPath, ref, options);
}
async search(
repoPath: string,
search: SearchPattern,
{
label,
reveal,
}: {
label:
| string
| {
label: string;
resultsType?: { singular: string; plural: string };
};
reveal?: {
select?: boolean;
focus?: boolean;
expand?: boolean | number;
};
},
results?: Promise<GitLog | undefined> | GitLog,
updateNode?: SearchResultsNode,
) {
if (!this.visible) {
await this.show();
}
const labels = { label: `Results ${typeof label === 'string' ? label : label.label}`, queryLabel: label };
if (updateNode != null) {
await updateNode.edit({ pattern: search, labels: labels, log: results });
return;
}
await this.addResults(new SearchResultsNode(this, this.root!, repoPath, search, labels, results), reveal);
}
getPinned() {
let savedPins = this.container.storage.getWorkspace('views:searchAndCompare:pinned');
if (savedPins == null) {
// Migrate any deprecated pinned items
const deprecatedPins = this.container.storage.getWorkspace('pinned:comparisons');
if (deprecatedPins == null) return [];
savedPins = Object.create(null) as StoredPinnedItems;
for (const p of Object.values(deprecatedPins)) {
savedPins[CompareResultsNode.getPinnableId(p.path, p.ref1.ref, p.ref2.ref)] = {
type: 'comparison',
timestamp: Date.now(),
path: p.path,
ref1: p.ref1,
ref2: p.ref2,
};
}
void this.container.storage.storeWorkspace('views:searchAndCompare:pinned', savedPins);
void this.container.storage.deleteWorkspace('pinned:comparisons');
}
const migratedPins = Object.create(null) as StoredPinnedItems;
let migrated = false;
const root = this.ensureRoot();
const pins = Object.entries(savedPins)
.sort(([, a], [, b]) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
.map(([k, p]) => {
if (p.type === 'comparison') {
// Migrated any old keys (sha1) to new keys (md5)
const key = CompareResultsNode.getPinnableId(p.path, p.ref1.ref, p.ref2.ref);
if (k !== key) {
migrated = true;
migratedPins[key] = p;
} else {
migratedPins[k] = p;
}
return new CompareResultsNode(
this,
root,
p.path,
{ label: p.ref1.label, ref: p.ref1.ref ?? (p.ref1 as any).name ?? (p.ref1 as any).sha },
{ label: p.ref2.label, ref: p.ref2.ref ?? (p.ref2 as any).name ?? (p.ref2 as any).sha },
p.timestamp,
);
}
// Migrated any old keys (sha1) to new keys (md5)
const key = SearchResultsNode.getPinnableId(p.path, p.search);
if (k !== key) {
migrated = true;
migratedPins[key] = p;
} else {
migratedPins[k] = p;
}
return new SearchResultsNode(this, root, p.path, p.search, p.labels, undefined, p.timestamp);
});
if (migrated) {
void this.container.storage.storeWorkspace('views:searchAndCompare:pinned', migratedPins);
}
return pins;
}
async updatePinned(id: string, pin?: StoredPinnedItem) {
let pinned = this.container.storage.getWorkspace('views:searchAndCompare:pinned');
pinned = updateRecordValue(pinned, id, pin);
await this.container.storage.storeWorkspace('views:searchAndCompare:pinned', pinned);
this.triggerNodeChange(this.ensureRoot());
}
@gate(() => '')
async revealRepository(
repoPath: string,
options?: { select?: boolean; focus?: boolean; expand?: boolean | number },
) {
const node = await this.findNode(RepositoryFolderNode.getId(repoPath), {
maxDepth: 1,
canTraverse: n => n instanceof SearchAndCompareViewNode || n instanceof RepositoryFolderNode,
});
if (node !== undefined) {
await this.reveal(node, options);
}
return node;
}
private async addResults(
results: CompareResultsNode | SearchResultsNode,
options: {
expand?: boolean | number;
focus?: boolean;
select?: boolean;
} = { expand: true, focus: true, select: true },
) {
if (!this.visible) {
await this.show();
}
const root = this.ensureRoot();
root.addOrReplace(results, !this.keepResults);
queueMicrotask(() => this.reveal(results, options));
}
private setFilesLayout(layout: ViewFilesLayout) {
return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout);
}
private setKeepResults(enabled: boolean) {
void this.container.storage.storeWorkspace('views:searchAndCompare:keepResults', enabled);
void setContext(ContextKeys.ViewsSearchAndCompareKeepResults, enabled);
}
private setShowAvatars(enabled: boolean) {
return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled);
}
private pin(node: CompareResultsNode | SearchResultsNode) {
if (!(node instanceof CompareResultsNode) && !(node instanceof SearchResultsNode)) return undefined;
return node.pin();
}
private setFilesFilter(node: ResultsFilesNode, filter: FilesQueryFilter | undefined) {
if (!(node instanceof ResultsFilesNode)) return;
node.filter = filter;
}
private swapComparison(node: CompareResultsNode) {
if (!(node instanceof CompareResultsNode)) return undefined;
return node.swap();
}
private unpin(node: CompareResultsNode | SearchResultsNode) {
if (!(node instanceof CompareResultsNode) && !(node instanceof SearchResultsNode)) return undefined;
return node.unpin();
}
}