1137 lines
34 KiB

import type { CancellationToken, ConfigurationChangeEvent, TextDocumentShowOptions, ViewColumn } from 'vscode';
import { CancellationTokenSource, Disposable, Uri, window } from 'vscode';
import type { MaybeEnrichedAutolink } from '../../annotations/autolinks';
import { serializeAutolink } from '../../annotations/autolinks';
import type { CopyShaToClipboardCommandArgs } from '../../commands/copyShaToClipboard';
import type { CoreConfiguration } from '../../constants';
import { Commands } from '../../constants';
import type { Container } from '../../container';
import type { CommitSelectedEvent } from '../../eventBus';
import { executeGitCommand } from '../../git/actions';
import {
openChanges,
openChangesWithWorking,
openFile,
openFileOnRemote,
showDetailsQuickPick,
} from '../../git/actions/commit';
import { CommitFormatter } from '../../git/formatters/commitFormatter';
import type { GitCommit } from '../../git/models/commit';
import { isCommit } from '../../git/models/commit';
import { uncommitted } from '../../git/models/constants';
import type { GitFileChange, GitFileChangeShape } from '../../git/models/file';
import type { IssueOrPullRequest } from '../../git/models/issue';
import { serializeIssueOrPullRequest } from '../../git/models/issue';
import type { PullRequest } from '../../git/models/pullRequest';
import { serializePullRequest } from '../../git/models/pullRequest';
import type { GitRevisionReference } from '../../git/models/reference';
import { createReference, getReferenceFromRevision, shortenRevision } from '../../git/models/reference';
import type { GitRemote } from '../../git/models/remote';
import type { Repository } from '../../git/models/repository';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol';
import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation';
import { executeCommand, executeCoreCommand, registerCommand } from '../../system/command';
import { configuration } from '../../system/configuration';
import { getContext } from '../../system/context';
import { debug } from '../../system/decorators/log';
import type { Deferrable } from '../../system/function';
import { debounce } from '../../system/function';
import { filterMap, map } from '../../system/iterable';
import { Logger } from '../../system/logger';
import { getLogScope } from '../../system/logger.scope';
import { MRU } from '../../system/mru';
import { getSettledValue } from '../../system/promise';
import type { Serialized } from '../../system/serialize';
import { serialize } from '../../system/serialize';
import type { LinesChangeEvent } from '../../trackers/lineTracker';
import type { IpcMessage } from '../protocol';
import { onIpc } from '../protocol';
import type { WebviewController, WebviewProvider } from '../webviewController';
import { updatePendingContext } from '../webviewController';
import { isSerializedState } from '../webviewsController';
import type {
CommitDetails,
DidExplainParams,
FileActionParams,
Mode,
Preferences,
State,
SwitchModeParams,
UpdateablePreferences,
Wip,
WipChange,
} from './protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
DidChangeWipStateNotificationType,
DidExplainCommandType,
ExplainCommandType,
FileActionsCommandType,
messageHeadlineSplitterToken,
NavigateCommitCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenFileOnRemoteCommandType,
PickCommitCommandType,
PinCommitCommandType,
SearchCommitCommandType,
StageFileCommandType,
SwitchModeCommandType,
UnstageFileCommandType,
UpdatePreferencesCommandType,
} from './protocol';
type RepositorySubscription = { repo: Repository; subscription: Disposable };
interface Context {
mode: Mode;
navigationStack: {
count: number;
position: number;
hint?: string;
};
pinned: boolean;
preferences: Preferences;
visible: boolean;
commit: GitCommit | undefined;
richStateLoaded: boolean;
formattedMessage: string | undefined;
autolinkedIssues: IssueOrPullRequest[] | undefined;
pullRequest: PullRequest | undefined;
wip: Wip | undefined;
}
export class CommitDetailsWebviewProvider implements WebviewProvider<State, Serialized<State>> {
private _bootstraping = true;
/** The context the webview has */
private _context: Context;
/** The context the webview should have */
private _pendingContext: Partial<Context> | undefined;
private readonly _disposable: Disposable;
private _pinned = false;
private _focused = false;
private _commitStack = new MRU<GitRevisionReference>(10, (a, b) => a.ref === b.ref);
constructor(
private readonly container: Container,
private readonly host: WebviewController<State, Serialized<State>>,
private readonly options: { attachedTo: 'default' | 'graph' },
) {
this._context = {
mode: 'commit',
navigationStack: {
count: 0,
position: 0,
},
pinned: false,
preferences: {
autolinksExpanded: this.container.storage.getWorkspace('views:commitDetails:autolinksExpanded') ?? true,
avatars: configuration.get('views.commitDetails.avatars'),
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
files: configuration.get('views.commitDetails.files'),
// indent: configuration.getAny('workbench.tree.indent') ?? 8,
indentGuides:
configuration.getAny<CoreConfiguration, Preferences['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
},
visible: false,
commit: undefined,
richStateLoaded: false,
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
wip: undefined,
};
this._disposable = configuration.onDidChangeAny(this.onAnyConfigurationChanged, this);
}
dispose() {
this._disposable.dispose();
this._commitTrackerDisposable?.dispose();
this._lineTrackerDisposable?.dispose();
this._repositorySubscription?.subscription.dispose();
this._wipSubscription?.subscription.dispose();
}
onReloaded(): void {
void this.notifyDidChangeState(true);
}
async onShowing(
_loading: boolean,
options: { column?: ViewColumn; preserveFocus?: boolean },
...args: [Partial<CommitSelectedEvent['data']> | { state: Partial<Serialized<State>> }] | unknown[]
): Promise<boolean> {
let data: Partial<CommitSelectedEvent['data']> | undefined;
const [arg] = args;
if (isSerializedState<Serialized<State>>(arg)) {
const { commit: selected } = arg.state;
if (selected?.repoPath != null && selected?.sha != null) {
if (selected.stashNumber != null) {
data = {
commit: createReference(selected.sha, selected.repoPath, {
refType: 'stash',
name: selected.message,
number: selected.stashNumber,
}),
};
} else {
data = {
commit: createReference(selected.sha, selected.repoPath, {
refType: 'revision',
message: selected.message,
}),
};
}
}
} else if (arg != null && typeof arg === 'object') {
data = arg as Partial<CommitSelectedEvent['data']> | undefined;
} else {
data = undefined;
}
let commit;
if (data != null) {
if (data.preserveFocus) {
options.preserveFocus = true;
}
({ commit, ...data } = data);
}
if (commit != null && this.mode === 'wip' && data?.interaction !== 'passive') {
this.setMode('commit');
}
if (commit == null) {
if (!this._pinned) {
commit = this.getBestCommitOrStash();
}
}
if (commit != null && !this._context.commit?.ref.startsWith(commit.ref)) {
await this.updateCommit(commit, { pinned: false });
}
if (data?.preserveVisibility && !this.host.visible) return false;
return true;
}
includeBootstrap(): Promise<Serialized<State>> {
this._bootstraping = true;
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
return this.getState(this._context);
}
registerCommands(): Disposable[] {
return [registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true))];
}
private onCommitSelected(e: CommitSelectedEvent) {
if (
e.data == null ||
(this.options.attachedTo === 'graph' && e.source !== 'gitlens.views.graph') ||
(this.options.attachedTo === 'default' && e.source === 'gitlens.views.graph')
) {
return;
}
if (this.mode === 'wip') {
if (e.data.commit.repoPath !== this._context.wip?.changes?.repository.path) {
void this.updateWipState(this.container.git.getRepository(e.data.commit.repoPath));
}
return;
}
if (this._pinned && e.data.interaction === 'passive') {
this._commitStack.insert(getReferenceFromRevision(e.data.commit));
this.updateNavigation();
} else {
void this.host.show(false, { preserveFocus: e.data.preserveFocus }, e.data);
}
}
onFocusChanged(focused: boolean): void {
if (this._focused === focused) return;
this._focused = focused;
if (focused && this.isLineTrackerSuspended) {
this.ensureTrackers();
}
}
onVisibilityChanged(visible: boolean) {
this.ensureTrackers();
this.updatePendingContext({ visible: visible });
if (!visible) return;
// Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data
if (this._bootstraping) {
this._bootstraping = false;
if (this._pendingContext == null) return;
this.updateState();
} else {
this.onRefresh();
this.updateState(true);
}
}
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
if (
configuration.changed(e, [
'defaultDateFormat',
'views.commitDetails.files',
'views.commitDetails.avatars',
]) ||
configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.renderIndentGuides')
) {
this.updatePendingContext({
preferences: {
...this._context.preferences,
...this._pendingContext?.preferences,
avatars: configuration.get('views.commitDetails.avatars'),
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
files: configuration.get('views.commitDetails.files'),
indentGuides:
configuration.getAny<CoreConfiguration, Preferences['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
},
});
this.updateState();
}
if (
this._context.commit != null &&
configuration.changed(e, ['views.commitDetails.autolinks', 'views.commitDetails.pullRequests'])
) {
void this.updateCommit(this._context.commit, { force: true });
this.updateState();
}
}
private _commitTrackerDisposable: Disposable | undefined;
private _lineTrackerDisposable: Disposable | undefined;
private ensureTrackers(): void {
this._commitTrackerDisposable?.dispose();
this._commitTrackerDisposable = undefined;
this._lineTrackerDisposable?.dispose();
this._lineTrackerDisposable = undefined;
if (!this.host.visible) return;
this._commitTrackerDisposable = this.container.events.on('commit:selected', this.onCommitSelected, this);
if (this._pinned) return;
if (this.options.attachedTo !== 'graph') {
const { lineTracker } = this.container;
this._lineTrackerDisposable = lineTracker.subscribe(
this,
lineTracker.onDidChangeActiveLines(this.onActiveEditorLinesChanged, this),
);
}
}
private get isLineTrackerSuspended() {
return this.options.attachedTo !== 'graph' ? this._lineTrackerDisposable == null : false;
}
private suspendLineTracker() {
// Defers the suspension of the line tracker, so that the focus change event can be handled first
setTimeout(() => {
this._lineTrackerDisposable?.dispose();
this._lineTrackerDisposable = undefined;
}, 100);
}
onRefresh(_force?: boolean | undefined): void {
if (this._pinned) return;
if (this.mode === 'wip') {
const uri = this._context.wip?.changes?.repository.uri;
void this.updateWipState(
this.container.git.getBestRepositoryOrFirst(uri != null ? Uri.parse(uri) : undefined),
);
} else {
const commit = this._pendingContext?.commit ?? this.getBestCommitOrStash();
void this.updateCommit(commit, { immediate: false });
}
}
onMessageReceived(e: IpcMessage) {
switch (e.method) {
case OpenFileOnRemoteCommandType.method:
onIpc(OpenFileOnRemoteCommandType, e, params => void this.openFileOnRemote(params));
break;
case OpenFileCommandType.method:
onIpc(OpenFileCommandType, e, params => void this.openFile(params));
break;
case OpenFileCompareWorkingCommandType.method:
onIpc(OpenFileCompareWorkingCommandType, e, params => void this.openFileComparisonWithWorking(params));
break;
case OpenFileComparePreviousCommandType.method:
onIpc(
OpenFileComparePreviousCommandType,
e,
params => void this.openFileComparisonWithPrevious(params),
);
break;
case FileActionsCommandType.method:
onIpc(FileActionsCommandType, e, params => void this.showFileActions(params));
break;
case CommitActionsCommandType.method:
onIpc(CommitActionsCommandType, e, params => {
switch (params.action) {
case 'graph': {
let ref: GitRevisionReference | undefined;
if (this._context.mode === 'wip') {
ref =
this._context.wip?.changes != null
? createReference(uncommitted, this._context.wip.changes.repository.path, {
refType: 'revision',
})
: undefined;
} else {
ref =
this._context.commit != null
? getReferenceFromRevision(this._context.commit)
: undefined;
}
if (ref == null) return;
void executeCommand<ShowInCommitGraphCommandArgs>(
this.options.attachedTo === 'graph'
? Commands.ShowInCommitGraphView
: Commands.ShowInCommitGraph,
{ ref: ref },
);
break;
}
case 'more':
this.showCommitActions();
break;
case 'scm':
void executeCoreCommand('workbench.view.scm');
break;
case 'sha':
if (params.alt) {
this.showCommitPicker();
} else if (this._context.commit != null) {
void executeCommand<CopyShaToClipboardCommandArgs>(Commands.CopyShaToClipboard, {
sha: this._context.commit.sha,
});
}
break;
}
});
break;
case PickCommitCommandType.method:
onIpc(PickCommitCommandType, e, _params => this.showCommitPicker());
break;
case SearchCommitCommandType.method:
onIpc(SearchCommitCommandType, e, _params => this.showCommitSearch());
break;
case SwitchModeCommandType.method:
onIpc(SwitchModeCommandType, e, params => this.switchMode(params));
break;
case AutolinkSettingsCommandType.method:
onIpc(AutolinkSettingsCommandType, e, _params => this.showAutolinkSettings());
break;
case PinCommitCommandType.method:
onIpc(PinCommitCommandType, e, params => this.updatePinned(params.pin ?? false, true));
break;
case NavigateCommitCommandType.method:
onIpc(NavigateCommitCommandType, e, params => this.navigateStack(params.direction));
break;
case UpdatePreferencesCommandType.method:
onIpc(UpdatePreferencesCommandType, e, params => this.updatePreferences(params));
break;
case ExplainCommandType.method:
onIpc(ExplainCommandType, e, () => this.explainCommit(e.completionId));
break;
case StageFileCommandType.method:
onIpc(StageFileCommandType, e, params => this.stageFile(params));
break;
case UnstageFileCommandType.method:
onIpc(UnstageFileCommandType, e, params => this.unstageFile(params));
break;
}
}
private onActiveEditorLinesChanged(e: LinesChangeEvent) {
if (e.pending || e.editor == null || e.suspended) return;
if (this.mode === 'wip') {
const repo = this.container.git.getBestRepositoryOrFirst(e.editor);
void this.updateWipState(repo);
return;
}
const line = e.selections?.[0]?.active;
const commit = line != null ? this.container.lineTracker.getState(line)?.commit : undefined;
void this.updateCommit(commit);
}
private _wipSubscription: RepositorySubscription | undefined;
private get mode(): Mode {
return this._pendingContext?.mode ?? this._context.mode;
}
private setMode(mode: Mode, repository?: Repository) {
this.updatePendingContext({ mode: mode });
if (mode === 'commit') {
this._wipSubscription?.subscription.dispose();
this._wipSubscription = undefined;
this.updateState(true);
} else {
void this.updateWipState(repository ?? this.container.git.getBestRepositoryOrFirst());
}
}
private async explainCommit(completionId?: string) {
let params: DidExplainParams;
try {
const summary = await this.container.ai.explainCommit(this._context.commit!, {
progress: { location: { viewId: this.host.id } },
});
params = { summary: summary };
} catch (ex) {
debugger;
params = { error: { message: ex.message } };
}
void this.host.notify(DidExplainCommandType, params, completionId);
}
private navigateStack(direction: 'back' | 'forward') {
const commit = this._commitStack.navigate(direction);
if (commit == null) return;
void this.updateCommit(commit, { immediate: true, skipStack: true });
}
private _cancellationTokenSource: CancellationTokenSource | undefined = undefined;
@debug({ args: false })
protected async getState(current: Context): Promise<Serialized<State>> {
if (this._cancellationTokenSource != null) {
this._cancellationTokenSource.cancel();
this._cancellationTokenSource = undefined;
}
let details;
if (current.commit != null) {
details = await this.getDetailsModel(current.commit, current.formattedMessage);
if (!current.richStateLoaded) {
this._cancellationTokenSource = new CancellationTokenSource();
const cancellation = this._cancellationTokenSource.token;
setTimeout(() => {
if (cancellation.isCancellationRequested) return;
void this.updateRichState(current, cancellation);
}, 100);
}
}
const state = serialize<State>({
mode: current.mode,
webviewId: this.host.id,
timestamp: Date.now(),
commit: details,
navigationStack: current.navigationStack,
pinned: current.pinned,
preferences: current.preferences,
includeRichContent: current.richStateLoaded,
autolinkedIssues: current.autolinkedIssues?.map(serializeIssueOrPullRequest),
pullRequest: current.pullRequest != null ? serializePullRequest(current.pullRequest) : undefined,
wip: current.wip,
});
return state;
}
@debug({ args: false })
private async updateWipState(repository: Repository | undefined): Promise<void> {
if (this._wipSubscription != null) {
const { repo, subscription } = this._wipSubscription;
if (repository?.path !== repo.path) {
subscription.dispose();
this._wipSubscription = undefined;
}
}
let wip: Wip | undefined = undefined;
if (repository != null) {
if (this._wipSubscription == null) {
this._wipSubscription = { repo: repository, subscription: this.subscribeToRepositoryWip(repository) };
}
const changes = await this.getWipChange(repository);
wip = { changes: changes, repositoryCount: this.container.git.openRepositoryCount };
if (this._pendingContext == null) {
const success = await this.host.notify(DidChangeWipStateNotificationType, { wip: wip });
if (success) {
this._context.wip = wip;
return;
}
}
}
this.updatePendingContext({ wip: wip });
this.updateState(true);
}
@debug({ args: false })
private async updateRichState(current: Context, cancellation: CancellationToken): Promise<void> {
const { commit } = current;
if (commit == null) return;
const remote = await this.container.git.getBestRemoteWithRichProvider(commit.repoPath);
if (cancellation.isCancellationRequested) return;
const [enrichedAutolinksResult, prResult] =
remote?.provider != null
? await Promise.allSettled([
configuration.get('views.commitDetails.autolinks.enabled') &&
configuration.get('views.commitDetails.autolinks.enhanced')
? pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote))
: undefined,
configuration.get('views.commitDetails.pullRequests.enabled')
? commit.getAssociatedPullRequest(remote)
: undefined,
])
: [];
if (cancellation.isCancellationRequested) return;
const enrichedAutolinks = getSettledValue(enrichedAutolinksResult)?.value;
const pr = getSettledValue(prResult);
const formattedMessage = this.getFormattedMessage(commit, remote, enrichedAutolinks);
this.updatePendingContext({
richStateLoaded: true,
formattedMessage: formattedMessage,
autolinkedIssues:
enrichedAutolinks != null
? [...filterMap(enrichedAutolinks.values(), ([issueOrPullRequest]) => issueOrPullRequest?.value)]
: undefined,
pullRequest: pr,
});
this.updateState();
// return {
// formattedMessage: formattedMessage,
// pullRequest: pr,
// autolinkedIssues:
// autolinkedIssuesAndPullRequests != null
// ? [...autolinkedIssuesAndPullRequests.values()].filter(<T>(i: T | undefined): i is T => i != null)
// : undefined,
// };
}
private _repositorySubscription: RepositorySubscription | undefined;
private async updateCommit(
commitish: GitCommit | GitRevisionReference | undefined,
options?: { force?: boolean; pinned?: boolean; immediate?: boolean; skipStack?: boolean },
) {
// this.commits = [commit];
if (!options?.force && this._context.commit?.sha === commitish?.ref) return;
let commit: GitCommit | undefined;
if (isCommit(commitish)) {
commit = commitish;
} else if (commitish != null) {
if (commitish.refType === 'stash') {
const stash = await this.container.git.getStash(commitish.repoPath);
commit = stash?.commits.get(commitish.ref);
} else {
commit = await this.container.git.getCommit(commitish.repoPath, commitish.ref);
}
}
let wip = this._pendingContext?.wip ?? this._context.wip;
if (this._repositorySubscription != null) {
const { repo, subscription } = this._repositorySubscription;
if (commit?.repoPath !== repo.path) {
subscription.dispose();
this._repositorySubscription = undefined;
wip = undefined;
}
}
if (this._repositorySubscription == null && commit != null) {
const repo = await this.container.git.getOrOpenRepository(commit.repoPath);
if (repo != null) {
this._repositorySubscription = { repo: repo, subscription: this.subscribeToRepositoryWip(repo) };
if (this.mode === 'wip') {
void this.updateWipState(repo);
} else {
wip = undefined;
}
}
}
this.updatePendingContext(
{
commit: commit,
richStateLoaded: Boolean(commit?.isUncommitted) || !getContext('gitlens:hasConnectedRemotes'),
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
wip: wip,
},
options?.force,
);
if (options?.pinned != null) {
this.updatePinned(options?.pinned);
}
if (this.isLineTrackerSuspended) {
this.ensureTrackers();
}
if (commit != null) {
if (!options?.skipStack) {
this._commitStack.add(getReferenceFromRevision(commit));
}
this.updateNavigation();
}
this.updateState(options?.immediate ?? true);
}
private subscribeToRepositoryWip(repo: Repository) {
return Disposable.from(
repo.startWatchingFileSystem(),
repo.onDidChangeFileSystem(() => this.onWipChanged(repo)),
repo.onDidChange(e => {
if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) {
this.onWipChanged(repo);
}
}),
);
}
private onWipChanged(repository: Repository) {
void this.updateWipState(repository);
}
private async getWipChange(repository: Repository): Promise<WipChange | undefined> {
const status = await this.container.git.getStatusForRepo(repository.path);
if (status == null) return undefined;
const files: GitFileChangeShape[] = [];
for (const file of status.files) {
const change = {
repoPath: file.repoPath,
path: file.path,
status: file.status,
originalPath: file.originalPath,
staged: file.staged,
};
files.push(change);
if (file.staged && file.wip) {
files.push({
...change,
staged: false,
});
}
}
return {
repository: {
name: repository.name,
path: repository.path,
uri: repository.uri.toString(),
},
branchName: status.branch,
files: files,
};
}
private updatePinned(pinned: boolean, immediate?: boolean) {
if (pinned === this._context.pinned) return;
this._pinned = pinned;
this.ensureTrackers();
this.updatePendingContext({ pinned: pinned });
this.updateState(immediate);
}
private updatePreferences(preferences: UpdateablePreferences) {
if (
this._context.preferences?.autolinksExpanded === preferences.autolinksExpanded &&
this._context.preferences?.files?.compact === preferences.files?.compact &&
this._context.preferences?.files?.icon === preferences.files?.icon &&
this._context.preferences?.files?.layout === preferences.files?.layout &&
this._context.preferences?.files?.threshold === preferences.files?.threshold
) {
return;
}
const changes: Preferences = {
...this._context.preferences,
...this._pendingContext?.preferences,
};
if (
preferences.autolinksExpanded != null &&
this._context.preferences?.autolinksExpanded !== preferences.autolinksExpanded
) {
void this.container.storage.storeWorkspace(
'views:commitDetails:autolinksExpanded',
preferences.autolinksExpanded,
);
changes.autolinksExpanded = preferences.autolinksExpanded;
}
if (preferences.files != null) {
if (this._context.preferences?.files?.compact !== preferences.files?.compact) {
void configuration.updateEffective('views.commitDetails.files.compact', preferences.files?.compact);
}
if (this._context.preferences?.files?.icon !== preferences.files?.icon) {
void configuration.updateEffective('views.commitDetails.files.icon', preferences.files?.icon);
}
if (this._context.preferences?.files?.layout !== preferences.files?.layout) {
void configuration.updateEffective('views.commitDetails.files.layout', preferences.files?.layout);
}
if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) {
void configuration.updateEffective('views.commitDetails.files.threshold', preferences.files?.threshold);
}
changes.files = preferences.files;
}
this.updatePendingContext({ preferences: changes });
this.updateState();
}
private updatePendingContext(context: Partial<Context>, force: boolean = false): boolean {
const [changed, pending] = updatePendingContext(this._context, this._pendingContext, context, force);
if (changed) {
this._pendingContext = pending;
}
return changed;
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
private updateState(immediate: boolean = false) {
if (immediate) {
void this.notifyDidChangeState();
return;
}
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
}
private updateNavigation() {
let sha = this._commitStack.get(this._commitStack.position - 1)?.ref;
if (sha != null) {
sha = shortenRevision(sha);
}
this.updatePendingContext({
navigationStack: {
count: this._commitStack.count,
position: this._commitStack.position,
hint: sha,
},
});
this.updateState();
}
private async notifyDidChangeState(force: boolean = false) {
const scope = getLogScope();
this._notifyDidChangeStateDebounced?.cancel();
if (!force && this._pendingContext == null) return false;
let context: Context;
if (this._pendingContext != null) {
context = { ...this._context, ...this._pendingContext };
this._context = context;
this._pendingContext = undefined;
} else {
context = this._context;
}
return window.withProgress({ location: { viewId: this.host.id } }, async () => {
try {
await this.host.notify(DidChangeNotificationType, {
state: await this.getState(context),
});
} catch (ex) {
Logger.error(scope, ex);
debugger;
}
});
}
private getBestCommitOrStash(): GitCommit | GitRevisionReference | undefined {
if (this._pinned) return undefined;
let commit;
if (this.options.attachedTo !== 'graph' && window.activeTextEditor != null) {
const { lineTracker } = this.container;
const line = lineTracker.selections?.[0].active;
if (line != null) {
commit = lineTracker.getState(line)?.commit;
}
} else {
commit = this._pendingContext?.commit;
if (commit == null) {
const args = this.container.events.getCachedEventArgs('commit:selected');
commit = args?.commit;
}
}
return commit;
}
private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise<CommitDetails> {
const [commitResult, avatarUriResult, remoteResult] = await Promise.allSettled([
!commit.hasFullDetails() ? commit.ensureFullDetails().then(() => commit) : commit,
commit.author.getAvatarUri(commit, { size: 32 }),
this.container.git.getBestRemoteWithRichProvider(commit.repoPath, { includeDisconnected: true }),
]);
commit = getSettledValue(commitResult, commit);
const avatarUri = getSettledValue(avatarUriResult);
const remote = getSettledValue(remoteResult);
if (formattedMessage == null) {
formattedMessage = this.getFormattedMessage(commit, remote);
}
const autolinks =
commit.message != null ? this.container.autolinks.getAutolinks(commit.message, remote) : undefined;
return {
repoPath: commit.repoPath,
sha: commit.sha,
shortSha: commit.shortSha,
author: { ...commit.author, avatar: avatarUri?.toString(true) },
// committer: { ...commit.committer, avatar: committerAvatar?.toString(true) },
message: formattedMessage,
parents: commit.parents,
stashNumber: commit.refType === 'stash' ? commit.number : undefined,
files: commit.files,
stats: commit.stats,
autolinks: autolinks != null ? [...map(autolinks.values(), serializeAutolink)] : undefined,
};
}
private getFormattedMessage(
commit: GitCommit,
remote: GitRemote | undefined,
enrichedAutolinks?: Map<string, MaybeEnrichedAutolink>,
) {
let message = CommitFormatter.fromTemplate(`\${message}`, commit);
const index = message.indexOf('\n');
if (index !== -1) {
message = `${message.substring(0, index)}${messageHeadlineSplitterToken}${message.substring(index + 1)}`;
}
if (!configuration.get('views.commitDetails.autolinks.enabled')) return message;
return this.container.autolinks.linkify(
message,
'html',
remote != null ? [remote] : undefined,
enrichedAutolinks,
);
}
private async getFileCommitFromParams(
params: FileActionParams,
): Promise<[commit: GitCommit, file: GitFileChange] | undefined> {
let commit: GitCommit | undefined;
if (this.mode === 'wip') {
const uri = this._context.wip?.changes?.repository.uri;
if (uri == null) return;
commit = await this.container.git.getCommit(Uri.parse(uri), uncommitted);
} else {
commit = this._context.commit;
}
commit = await commit?.getCommitForFile(params.path, params.staged);
return commit != null ? [commit, commit.file!] : undefined;
}
private showAutolinkSettings() {
void executeCommand(Commands.ShowSettingsPageAndJumpToAutolinks);
}
private showCommitPicker() {
void executeGitCommand({
command: 'log',
state: {
reference: 'HEAD',
repo: this._context.commit?.repoPath,
openPickInView: true,
},
});
}
private showCommitSearch() {
void executeGitCommand({ command: 'search', state: { openPickInView: true } });
}
private showCommitActions() {
if (this._context.commit == null || this._context.commit.isUncommitted) return;
void showDetailsQuickPick(this._context.commit);
}
private async showFileActions(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
this.suspendLineTracker();
void showDetailsQuickPick(commit, file);
}
private switchMode(params: SwitchModeParams) {
let repo;
if (params.mode === 'wip') {
let { repoPath } = params;
if (repoPath == null) {
repo = this.container.git.getBestRepositoryOrFirst();
if (repo == null) return;
repoPath = repo.path;
} else {
repo = this.container.git.getRepository(repoPath)!;
}
}
this.setMode(params.mode, repo);
}
private async openFileComparisonWithWorking(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
this.suspendLineTracker();
void openChangesWithWorking(file, commit, {
preserveFocus: true,
preview: true,
...this.getShowOptions(params),
});
}
private async openFileComparisonWithPrevious(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
this.suspendLineTracker();
void openChanges(file, commit, {
preserveFocus: true,
preview: true,
...this.getShowOptions(params),
});
this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id });
}
private async openFile(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
this.suspendLineTracker();
void openFile(file, commit, {
preserveFocus: true,
preview: true,
...this.getShowOptions(params),
});
}
private async openFileOnRemote(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
void openFileOnRemote(file, commit);
}
private async stageFile(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
await this.container.git.stageFile(commit.repoPath, file.path);
}
private async unstageFile(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
await this.container.git.unstageFile(commit.repoPath, file.path);
}
private getShowOptions(params: FileActionParams): TextDocumentShowOptions | undefined {
return params.showOptions;
// return getContext('gitlens:webview:graph:active') || getContext('gitlens:webview:rebase:active')
// ? { ...params.showOptions, viewColumn: ViewColumn.Beside } : params.showOptions;
}
}
// async function summaryModel(commit: GitCommit): Promise<CommitSummary> {
// return {
// sha: commit.sha,
// shortSha: commit.shortSha,
// summary: commit.summary,
// message: commit.message,
// author: commit.author,
// avatar: (await commit.getAvatarUri())?.toString(true),
// };
// }