浏览代码

Refines the commit details experience

- Adds editor following support & pinning
- Adds Commits view following support

Co-authored-by: Keith Daulton <kdaulton@d13design.com>
main
Eric Amodio 2 年前
父节点
当前提交
07df7058ac
共有 22 个文件被更改,包括 879 次插入447 次删除
  1. +10
    -11
      src/annotations/autolinks.ts
  2. +1
    -1
      src/commands/git/log.ts
  3. +1
    -1
      src/commands/git/search.ts
  4. +50
    -2
      src/commands/gitCommands.actions.ts
  5. +21
    -9
      src/git/models/commit.ts
  6. +11
    -3
      src/git/models/file.ts
  7. +8
    -8
      src/git/models/issue.ts
  8. +11
    -1
      src/git/models/pullRequest.ts
  9. +1
    -1
      src/quickpicks/items/commits.ts
  10. +17
    -0
      src/system/serialize.ts
  11. +0
    -3
      src/views/nodes/autolinkedItemsNode.ts
  12. +11
    -0
      src/views/viewBase.ts
  13. +15
    -2
      src/webviews/apps/commitDetails/commitDetails.html
  14. +16
    -8
      src/webviews/apps/commitDetails/commitDetails.scss
  15. +184
    -145
      src/webviews/apps/commitDetails/commitDetails.ts
  16. +2
    -2
      src/webviews/apps/shared/appBase.ts
  17. +4
    -2
      src/webviews/apps/shared/components/commit/commit-identity.ts
  18. +4
    -4
      src/webviews/apps/shared/components/converters/date-converter.ts
  19. +1
    -1
      src/webviews/apps/shared/components/formatted-date.ts
  20. +456
    -207
      src/webviews/commitDetails/commitDetailsWebviewView.ts
  21. +51
    -32
      src/webviews/commitDetails/protocol.ts
  22. +4
    -4
      src/webviews/webviewViewBase.ts

+ 10
- 11
src/annotations/autolinks.ts 查看文件

@ -11,7 +11,7 @@ import { Logger } from '../logger';
import { fromNow } from '../system/date';
import { debug } from '../system/decorators/log';
import { encodeUrl } from '../system/encoding';
import { every, join, map } from '../system/iterable';
import { join, map } from '../system/iterable';
import type { PromiseCancelledErrorWithId } from '../system/promise';
import { PromiseCancelledError, raceAll } from '../system/promise';
import { escapeMarkdown, escapeRegex, getSuperscript } from '../system/string';
@ -142,18 +142,13 @@ export class Autolinks implements Disposable {
message: string,
remote: GitRemote,
options?: { autolinks?: Map<string, Autolink>; timeout?: never },
): Promise<Map<string, IssueOrPullRequest | undefined> | undefined>;
): Promise<Map<string, IssueOrPullRequest> | undefined>;
async getLinkedIssuesAndPullRequests(
message: string,
remote: GitRemote,
options: { autolinks?: Map<string, Autolink>; timeout: number },
): Promise<
| Map<
string,
| IssueOrPullRequest
| PromiseCancelledErrorWithId<string, Promise<IssueOrPullRequest | undefined>>
| undefined
>
| Map<string, IssueOrPullRequest | PromiseCancelledErrorWithId<string, Promise<IssueOrPullRequest | undefined>>>
| undefined
>;
@debug<Autolinks['getLinkedIssuesAndPullRequests']>({
@ -186,11 +181,15 @@ export class Autolinks implements Disposable {
id => provider.getIssueOrPullRequest(id),
options?.timeout,
);
if (issuesOrPullRequests.size === 0 || every(issuesOrPullRequests.values(), pr => pr == null)) {
return undefined;
// Remove any issues or pull requests that were not found
for (const [id, issueOrPullRequest] of issuesOrPullRequests) {
if (issueOrPullRequest == null) {
issuesOrPullRequests.delete(id);
}
}
return issuesOrPullRequests;
return issuesOrPullRequests.size !== 0 ? issuesOrPullRequests : undefined;
}
@debug<Autolinks['linkify']>({

+ 1
- 1
src/commands/git/log.ts 查看文件

@ -191,7 +191,7 @@ export class LogGitCommand extends QuickCommand {
let result: StepResult<ReturnType<typeof getSteps>>;
if (state.openPickInView) {
void GitActions.Commit.openDetails(state.reference as GitCommit);
void GitActions.Commit.showDetailsView(state.reference as GitCommit);
result = StepResult.Break;
} else {
result = yield* getSteps(

+ 1
- 1
src/commands/git/search.ts 查看文件

@ -245,7 +245,7 @@ export class SearchGitCommand extends QuickCommand {
let result: StepResult<ReturnType<typeof getSteps>>;
if (state.openPickInView) {
void GitActions.Commit.openDetails(context.commit);
void GitActions.Commit.showDetailsView(context.commit);
result = StepResult.Break;
} else {
result = yield* getSteps(

+ 50
- 2
src/commands/gitCommands.actions.ts 查看文件

@ -5,7 +5,10 @@ import type {
DiffWithCommandArgs,
DiffWithWorkingCommandArgs,
GitCommandsCommandArgs,
OpenFileOnRemoteCommandArgs,
OpenWorkingFileCommandArgs,
ShowQuickCommitCommandArgs,
ShowQuickCommitFileCommandArgs,
} from '../commands';
import type { FileAnnotationType } from '../configuration';
import { Commands, CoreCommands } from '../constants';
@ -586,8 +589,26 @@ export namespace GitActions {
}
}
export async function openDetails(commit: GitCommit): Promise<void> {
await Container.instance.commitDetailsView.show({ commit: commit });
export async function openFileOnRemote(uri: Uri): Promise<void>;
export async function openFileOnRemote(file: string | GitFile, ref: GitRevisionReference): Promise<void>;
export async function openFileOnRemote(
fileOrUri: string | GitFile | Uri,
ref?: GitRevisionReference,
): Promise<void> {
let uri;
if (fileOrUri instanceof Uri) {
uri = fileOrUri;
} else {
if (ref == null) throw new Error('Invalid arguments');
uri = GitUri.fromFile(fileOrUri, ref.repoPath, ref.ref);
// If the file is `?` (untracked), then this must be an untracked file in a stash, so just return
if (typeof fileOrUri !== 'string' && fileOrUri.status === '?') return;
}
void (await executeCommand<[Uri, OpenFileOnRemoteCommandArgs]>(Commands.OpenFileOnRemote, uri, {
sha: ref?.ref,
}));
}
export async function openFiles(commit: GitCommit): Promise<void>;
@ -712,6 +733,33 @@ export namespace GitActions {
return undefined;
}
export async function showDetailsQuickPick(commit: GitCommit, uri?: Uri): Promise<void>;
export async function showDetailsQuickPick(commit: GitCommit, file?: string | GitFile): Promise<void>;
export async function showDetailsQuickPick(
commit: GitCommit,
fileOrUri?: string | GitFile | Uri,
): Promise<void> {
if (fileOrUri == null) {
void (await executeCommand<ShowQuickCommitCommandArgs>(Commands.ShowQuickCommit, { commit: commit }));
return;
}
let uri;
if (fileOrUri instanceof Uri) {
uri = fileOrUri;
} else {
uri = GitUri.fromFile(fileOrUri, commit.repoPath, commit.ref);
}
void (await executeCommand<[Uri, ShowQuickCommitFileCommandArgs]>(Commands.ShowQuickCommitFile, uri, {
sha: commit.sha,
}));
}
export function showDetailsView(commit: GitCommit): Promise<void> {
return Container.instance.commitDetailsView.show({ commit: commit });
}
}
export namespace Contributor {

+ 21
- 9
src/git/models/commit.ts 查看文件

@ -173,18 +173,24 @@ export class GitCommit implements GitRevisionReference {
);
}
hasFullDetails(commit: GitCommit): commit is GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'> {
hasFullDetails(): this is GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'> {
return (
commit.message != null &&
commit.files != null &&
commit.parents.length !== 0 &&
(commit.refType !== 'stash' || commit._stashUntrackedFilesLoaded)
this.message != null &&
this.files != null &&
this.parents.length !== 0 &&
(this.refType !== 'stash' || this._stashUntrackedFilesLoaded)
);
}
assertsFullDetails(): asserts this is GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'> {
if (!this.hasFullDetails()) {
throw new Error(`GitCommit(${this.sha}) is not fully loaded`);
}
}
@gate()
async ensureFullDetails(): Promise<void> {
if (this.isUncommitted || this.hasFullDetails(this)) return;
if (this.isUncommitted || this.hasFullDetails()) return;
const [commitResult, untrackedResult] = await Promise.allSettled([
this.refType !== 'stash' ? this.container.git.getCommit(this.repoPath, this.sha) : undefined,
@ -288,7 +294,7 @@ export class GitCommit implements GitRevisionReference {
async findFile(path: string): Promise<GitFileChange | undefined>;
async findFile(uri: Uri): Promise<GitFileChange | undefined>;
async findFile(pathOrUri: string | Uri): Promise<GitFileChange | undefined> {
if (!this.hasFullDetails(this)) {
if (!this.hasFullDetails()) {
await this.ensureFullDetails();
if (this._files == null) return undefined;
}
@ -412,7 +418,7 @@ export class GitCommit implements GitRevisionReference {
}
async getCommitsForFiles(): Promise<GitCommit[]> {
if (!this.hasFullDetails(this)) {
if (!this.hasFullDetails()) {
await this.ensureFullDetails();
if (this._files == null) return [];
}
@ -552,7 +558,13 @@ export function isOfCommitOrStashRefType(commit: GitReference | undefined): bool
return commit?.refType === 'revision' || commit?.refType === 'stash';
}
export class GitCommitIdentity {
export interface GitCommitIdentityShape {
readonly name: string;
readonly email: string | undefined;
readonly date: Date;
}
export class GitCommitIdentity implements GitCommitIdentityShape {
constructor(
public readonly name: string,
public readonly email: string | undefined,

+ 11
- 3
src/git/models/file.ts 查看文件

@ -40,13 +40,14 @@ export const enum GitFileWorkingTreeStatus {
}
export interface GitFile {
readonly path: string;
readonly originalPath?: string;
status: GitFileStatus;
readonly repoPath?: string;
readonly conflictStatus?: GitFileConflictStatus;
readonly indexStatus?: GitFileIndexStatus;
readonly workingTreeStatus?: GitFileWorkingTreeStatus;
readonly path: string;
readonly originalPath?: string;
}
export interface GitFileWithCommit extends GitFile {
@ -172,7 +173,14 @@ export interface GitFileChangeStats {
changes: number;
}
export class GitFileChange {
export interface GitFileChangeShape {
readonly path: string;
readonly originalPath?: string | undefined;
readonly status: GitFileStatus;
readonly repoPath: string;
}
export class GitFileChange implements GitFileChangeShape {
static is(file: any): file is GitFileChange {
return file instanceof GitFileChange;
}

+ 8
- 8
src/git/models/issue.ts 查看文件

@ -8,14 +8,14 @@ export const enum IssueOrPullRequestType {
}
export interface IssueOrPullRequest {
type: IssueOrPullRequestType;
provider: RemoteProviderReference;
id: string;
date: Date;
title: string;
closed: boolean;
closedDate?: Date;
url: string;
readonly type: IssueOrPullRequestType;
readonly provider: RemoteProviderReference;
readonly id: string;
readonly title: string;
readonly url: string;
readonly date: Date;
readonly closedDate?: Date;
readonly closed: boolean;
}
export namespace IssueOrPullRequest {

+ 11
- 1
src/git/models/pullRequest.ts 查看文件

@ -14,7 +14,17 @@ export const enum PullRequestState {
Merged = 'Merged',
}
export class PullRequest implements IssueOrPullRequest {
export interface PullRequestShape extends IssueOrPullRequest {
readonly author: {
readonly name: string;
readonly avatarUrl: string;
readonly url: string;
};
readonly state: PullRequestState;
readonly mergedDate?: Date;
}
export class PullRequest implements PullRequestShape {
static is(pr: any): pr is PullRequest {
return pr instanceof PullRequest;
}

+ 1
- 1
src/quickpicks/items/commits.ts 查看文件

@ -262,7 +262,7 @@ export class CommitOpenDetailsCommandQuickPickItem extends CommandQuickPickItem
}
override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise<void> {
return GitActions.Commit.openDetails(this.commit);
return GitActions.Commit.showDetailsView(this.commit);
}
}

+ 17
- 0
src/system/serialize.ts 查看文件

@ -0,0 +1,17 @@
export type Serialized<T> = T extends Function
? never
: T extends Date
? number
: T extends object
? {
[K in keyof T]: T[K] extends Date ? number : Serialized<T[K]>;
}
: T;
export function serialize<T extends object>(obj: T): Serialized<T> {
function replacer(this: any, key: string, value: unknown) {
const original = this[key];
return original instanceof Date ? original.getTime() : value;
}
return JSON.parse(JSON.stringify(obj, replacer)) as Serialized<T>;
}

+ 0
- 3
src/views/nodes/autolinkedItemsNode.ts 查看文件

@ -7,7 +7,6 @@ import { PullRequest } from '../../git/models/pullRequest';
import { gate } from '../../system/decorators/gate';
import { debug } from '../../system/decorators/log';
import { union } from '../../system/iterable';
import { PromiseCancelledErrorWithId } from '../../system/promise';
import type { ViewsWithCommits } from '../viewBase';
import { AutolinkedItemNode } from './autolinkedItemNode';
import { LoadMoreNode, MessageNode } from './common';
@ -63,8 +62,6 @@ export class AutolinkedItemsNode extends ViewNode {
if (autolinkedMapResult.status === 'fulfilled' && autolinkedMapResult.value != null) {
for (const [id, issue] of autolinkedMapResult.value) {
if (issue == null || issue instanceof PromiseCancelledErrorWithId) continue;
items.set(id, issue);
}
}

+ 11
- 0
src/views/viewBase.ts 查看文件

@ -6,6 +6,7 @@ import type {
TreeItem,
TreeView,
TreeViewExpansionEvent,
TreeViewSelectionChangeEvent,
TreeViewVisibilityChangeEvent,
} from 'vscode';
import { Disposable, EventEmitter, MarkdownString, TreeItemCollapsibleState, window } from 'vscode';
@ -86,6 +87,11 @@ export abstract class ViewBase<
return this._onDidChangeTreeData.event;
}
private _onDidChangeSelection = new EventEmitter<TreeViewSelectionChangeEvent<ViewNode>>();
get onDidChangeSelection(): Event<TreeViewSelectionChangeEvent<ViewNode>> {
return this._onDidChangeSelection.event;
}
private _onDidChangeVisibility = new EventEmitter<TreeViewVisibilityChangeEvent>();
get onDidChangeVisibility(): Event<TreeViewVisibilityChangeEvent> {
return this._onDidChangeVisibility.event;
@ -261,6 +267,7 @@ export abstract class ViewBase<
this.onConfigurationChanged(e);
}, this),
this.tree,
this.tree.onDidChangeSelection(debounce(this.onSelectionChanged, 250), this),
this.tree.onDidChangeVisibility(debounce(this.onVisibilityChanged, 250), this),
this.tree.onDidCollapseElement(this.onElementCollapsed, this),
this.tree.onDidExpandElement(this.onElementExpanded, this),
@ -303,6 +310,10 @@ export abstract class ViewBase<
this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Expanded });
}
protected onSelectionChanged(e: TreeViewSelectionChangeEvent<ViewNode>) {
this._onDidChangeSelection.fire(e);
}
protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) {
this._onDidChangeVisibility.fire(e);
}

+ 15
- 2
src/webviews/apps/commitDetails/commitDetails.html 查看文件

@ -9,7 +9,8 @@
<!-- commitDetails:header -->
<!-- commitDetails:choices -->
<div class="commit-detail-panel__none" id="empty" aria-hidden="true">
<p><code-icon icon="warning"></code-icon> <span>No commit selected</span></p>
<!-- <p><code-icon icon="warning"></code-icon> <span>No commit selected</span></p> -->
<p>Open a file with commit history or pick a specific commit to see commit details.</p>
<p class="button-container">
<span class="button-group">
<button class="button button--full" type="button" data-action="pick-commit">
@ -20,7 +21,11 @@
</button>
</span>
</p>
<!-- <p><button class="button button--full" type="button">Follow Open Files</button></p> -->
<!-- <p class="button-container">
<span class="button-group"
><button class="button button--full" type="button">Follow Open Files</button></span
>
</p> -->
</div>
<main class="commit-details commit-detail-panel__main" id="main" tabindex="-1">
<div class="commit-details__commit">
@ -33,6 +38,14 @@
<a
class="commit-details__commit-action"
href="#"
data-action="commit-actions-pin"
aria-label="Pin Commit to View"
title="Pin Commit to View"
><code-icon icon="pin" data-region="commit-pin"></code-icon
></a>
<a
class="commit-details__commit-action"
href="#"
data-action="commit-actions-sha"
aria-label="Copy SHA
[⌥] Pick Commit..."

+ 16
- 8
src/webviews/apps/commitDetails/commitDetails.scss 查看文件

@ -194,7 +194,7 @@ ul {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
// gap: 0.5rem;
}
&__message {
@ -209,12 +209,16 @@ ul {
margin: 0;
display: block;
@supports (-webkit-line-clamp: 6) {
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
overflow-y: auto;
overflow-x: hidden;
max-height: 9rem;
// @supports (-webkit-line-clamp: 6) {
// display: -webkit-box;
// -webkit-line-clamp: 6;
// -webkit-box-orient: vertical;
// overflow: hidden;
// }
strong {
font-weight: 600;
@ -259,10 +263,14 @@ ul {
}
&__rich {
padding: 0.5rem var(--gitlens-scrollbar-gutter-width) 1rem var(--gitlens-gutter-width);
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width);
> :first-child {
margin-top: 0;
}
> :last-child {
margin-top: 0.5rem;
margin-bottom: 0;
}
}
&__pull-request {

+ 184
- 145
src/webviews/apps/commitDetails/commitDetails.ts 查看文件

@ -1,18 +1,20 @@
/*global*/
import type { Serialized } from '../../../system/serialize';
import type { IpcMessage } from '../../../webviews/protocol';
import { onIpc } from '../../../webviews/protocol';
import type { CommitSummary, State } from '../../commitDetails/protocol';
import type { State } from '../../commitDetails/protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
FileComparePreviousCommandType,
FileCompareWorkingCommandType,
FileMoreActionsCommandType,
DidChangeRichStateNotificationType,
DidChangeStateNotificationType,
FileActionsCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenFileOnRemoteCommandType,
PickCommitCommandType,
RichContentNotificationType,
PinCommitCommandType,
SearchCommitCommandType,
} from '../../commitDetails/protocol';
import { App } from '../shared/appBase';
@ -25,18 +27,19 @@ import '../shared/components/formatted-date';
import '../shared/components/rich/issue-pull-request';
import '../shared/components/skeleton-loader';
import '../shared/components/commit/commit-stats';
import '../shared/components/commit/file-change-item';
import '../shared/components/webview-pane';
export class CommitDetailsApp extends App<State> {
type CommitState = SomeNonNullable<Serialized<State>, 'selected'>;
export class CommitDetailsApp extends App<Serialized<State>> {
constructor() {
super('CommitDetailsApp');
console.log('CommitDetailsApp', this.state);
this.log('CommitDetailsApp', this.state);
}
override onInitialize() {
console.log('CommitDetailsApp onInitialize', this.state);
this.log('CommitDetailsApp.onInitialize', this.state);
this.renderContent();
}
@ -75,11 +78,53 @@ export class CommitDetailsApp extends App {
$next?.focus();
}
}),
DOM.on('[data-action="commit-actions-pin"]', 'click', e => this.onTogglePin(e)),
];
return disposables;
}
protected override onMessageReceived(e: MessageEvent) {
const msg = e.data as IpcMessage;
switch (msg.method) {
case DidChangeRichStateNotificationType.method:
onIpc(DidChangeRichStateNotificationType, msg, params => {
if (this.state.selected == null) return;
assertsSerialized<typeof params>(params);
const newState = { ...this.state };
if (params.formattedMessage != null) {
newState.selected!.message = params.formattedMessage;
}
// if (params.pullRequest != null) {
newState.pullRequest = params.pullRequest;
// }
// if (params.formattedMessage != null) {
newState.autolinkedIssues = params.autolinkedIssues;
// }
this.state = newState;
this.renderRichContent();
});
break;
case DidChangeStateNotificationType.method:
onIpc(DidChangeStateNotificationType, msg, params => {
assertsSerialized<typeof params.state>(params.state);
// TODO: Undefined won't get serialized -- need to convert to null or something
this.state = params.state; //{ ...this.state, ...params.state };
this.renderContent();
});
break;
}
}
private onTogglePin(e: MouseEvent) {
e.preventDefault();
this.sendCommand(PinCommitCommandType, { pin: !this.state.pinned });
}
private onAutolinkSettings(e: MouseEvent) {
e.preventDefault();
this.sendCommand(AutolinkSettingsCommandType, undefined);
@ -102,15 +147,15 @@ export class CommitDetailsApp extends App {
}
private onCompareFileWithWorking(e: FileChangeItemEventDetail) {
this.sendCommand(FileCompareWorkingCommandType, e);
this.sendCommand(OpenFileCompareWorkingCommandType, e);
}
private onCompareFileWithPrevious(e: FileChangeItemEventDetail) {
this.sendCommand(FileComparePreviousCommandType, e);
this.sendCommand(OpenFileComparePreviousCommandType, e);
}
private onFileMoreActions(e: FileChangeItemEventDetail) {
this.sendCommand(FileMoreActionsCommandType, e);
this.sendCommand(FileActionsCommandType, e);
}
private onCommitMoreActions(e: MouseEvent) {
@ -133,37 +178,8 @@ export class CommitDetailsApp extends App {
this.sendCommand(CommitActionsCommandType, { action: 'sha', alt: e.altKey });
}
protected override onMessageReceived(e: MessageEvent) {
const msg = e.data as IpcMessage;
switch (msg.method) {
case RichContentNotificationType.method:
onIpc(RichContentNotificationType, msg, params => {
const newState = { ...this.state };
if (params.formattedMessage != null) {
newState.selected.message = params.formattedMessage;
}
if (params.pullRequest != null) {
newState.pullRequest = params.pullRequest;
}
if (params.formattedMessage != null) {
newState.issues = params.issues;
}
this.state = newState;
this.renderRichContent();
});
break;
case DidChangeNotificationType.method:
onIpc(DidChangeNotificationType, msg, params => {
this.state = { ...this.state, ...params.state };
this.renderContent();
});
break;
}
}
renderCommit() {
const hasSelection = this.state.selected !== undefined;
renderCommit(state: Serialized<State>): state is CommitState {
const hasSelection = state.selected !== undefined;
const $empty = document.getElementById('empty');
const $main = document.getElementById('main');
$empty?.setAttribute('aria-hidden', hasSelection ? 'true' : 'false');
@ -173,38 +189,51 @@ export class CommitDetailsApp extends App {
}
renderRichContent() {
if (!this.renderCommit()) {
return;
}
if (!this.renderCommit(this.state)) return;
this.renderMessage();
this.renderAutolinks();
this.renderMessage(this.state);
this.renderPullRequestAndAutolinks(this.state);
}
renderContent() {
if (!this.renderCommit()) {
if (!this.renderCommit(this.state)) {
return;
}
this.renderSha();
this.renderMessage();
this.renderAuthor();
this.renderStats();
this.renderFiles();
this.renderPin(this.state);
this.renderSha(this.state);
this.renderMessage(this.state);
this.renderAuthor(this.state);
this.renderStats(this.state);
this.renderFiles(this.state);
if (this.state.includeRichContent) {
this.renderAutolinks();
// if (this.state.includeRichContent) {
this.renderPullRequestAndAutolinks(this.state);
// }
}
renderPin(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-action="commit-actions-pin"]');
if ($el == null) {
return;
}
const label = state.pinned ? 'Unpin this Commit' : 'Pin this Commit';
$el.setAttribute('aria-label', label);
$el.setAttribute('title', label);
const $icon = $el.querySelector('[data-region="commit-pin"]');
$icon?.setAttribute('icon', state.pinned ? 'pinned' : 'pin');
}
renderSha() {
renderSha(state: CommitState) {
const $els = [...document.querySelectorAll<HTMLElement>('[data-region="shortsha"]')];
if ($els.length === 0) {
return;
}
$els.forEach($el => {
$el.textContent = this.state.selected.shortSha;
$el.textContent = state.selected.shortSha;
});
}
@ -227,60 +256,66 @@ export class CommitDetailsApp extends App {
return;
}
if (this.state.commits?.length) {
const $count = $el.querySelector<HTMLElement>('[data-region="choices-count"]');
if ($count != null) {
$count.innerHTML = `${this.state.commits.length}`;
}
const $list = $el.querySelector<HTMLElement>('[data-region="choices-list"]');
if ($list != null) {
$list.innerHTML = this.state.commits
.map(
(item: CommitSummary) => `
<li class="commit-detail-panel__commit">
<button class="commit-detail-panel__commit-button" type="button" ${
item.sha === this.state.selected.sha ? 'aria-current="true"' : ''
}>
<img src="${item.avatar}" alt="${item.author.name}" />
<span>${item.message}</span>
<span>${item.shortSha}</span>
</button>
</li>
`,
)
.join('');
}
$el.setAttribute('aria-hidden', 'false');
} else {
$el.setAttribute('aria-hidden', 'true');
$el.innerHTML = '';
}
// if (this.state.commits?.length) {
// const count=el.querySelector<HTMLElement>('[data-region="choices-count"]');
// if ($count != null) {
// count.innerHTML={this.state.commits.length}`;
// }
// const list=el.querySelector<HTMLElement>('[data-region="choices-list"]');
// if ($list != null) {
// $list.innerHTML = this.state.commits
// .map(
// (item: CommitSummary) => `
// <li class="commit-detail-panel__commit">
// <button class="commit-detail-panel__commit-button" type="button" ${
// item.sha === this.state.selected?.sha ? 'aria-current="true"' : ''
// }>
// <img src="item.avatar"alt="{item.author.name}" />
// <span>${item.message}</span>
// <span>${item.shortSha}</span>
// </button>
// </li>
// `,
// )
// .join('');
// }
// $el.setAttribute('aria-hidden', 'false');
// } else {
$el.setAttribute('aria-hidden', 'true');
$el.innerHTML = '';
// }
}
renderStats() {
renderStats(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="stats"]');
if ($el == null) {
return;
}
if (this.state.selected.stats?.changedFiles !== undefined) {
const { added, deleted, changed } = this.state.selected.stats.changedFiles;
$el.innerHTML = `
if (state.selected.stats?.changedFiles !== undefined) {
if (typeof state.selected.stats.changedFiles === 'number') {
$el.innerHTML = `
<commit-stats added="?" modified="${state.selected.stats.changedFiles}" removed="?"></commit-stats>
`;
} else {
const { added, deleted, changed } = state.selected.stats.changedFiles;
$el.innerHTML = `
<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>
`;
}
} else {
$el.innerHTML = '';
}
}
renderFiles() {
renderFiles(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="files"]');
if ($el == null) {
return;
}
if (this.state.selected.files?.length > 0) {
$el.innerHTML = this.state.selected.files
if (state.selected.files?.length) {
$el.innerHTML = state.selected.files
.map(
(file: Record<string, any>) => `
<li class="change-list__item">
@ -295,45 +330,20 @@ export class CommitDetailsApp extends App {
}
}
renderAuthor() {
renderAuthor(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="author"]');
if ($el == null) {
return;
}
if (this.state.selected.author != null) {
$el.innerHTML = `
<commit-identity
name="${this.state.selected.author.name}"
email="${this.state.selected.author.email}"
date="${this.state.selected.author.date}"
avatar="${this.state.selected.author.avatar}"
></commit-identity>
`;
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
renderCommitter() {
// <li class="commit-details__author" data-region="committer">
// <skeleton-loader></skeleton-loader>
// </li>
const $el = document.querySelector<HTMLElement>('[data-region="committer"]');
if ($el == null) {
return;
}
if (this.state.selected.committer != null) {
if (state.selected.author != null) {
$el.innerHTML = `
<commit-identity
name="${this.state.selected.committer.name}"
email="${this.state.selected.committer.email}"
date="${this.state.selected.committer.date}"
avatar="${this.state.selected.committer.avatar}"
committer
name="${state.selected.author.name}"
email="${state.selected.author.email}"
date=${state.selected.author.date}
dateFormat="${state.dateFormat}"
avatar="${state.selected.author.avatar}"
></commit-identity>
`;
$el.setAttribute('aria-hidden', 'false');
@ -343,7 +353,33 @@ export class CommitDetailsApp extends App {
}
}
renderTitle() {
// renderCommitter(state: CommitState) {
// // <li class="commit-details__author" data-region="committer">
// // <skeleton-loader></skeleton-loader>
// // </li>
// const $el = document.querySelector<HTMLElement>('[data-region="committer"]');
// if ($el == null) {
// return;
// }
// if (state.selected.committer != null) {
// $el.innerHTML = `
// <commit-identity
// name="${state.selected.committer.name}"
// email="${state.selected.committer.email}"
// date="${state.selected.committer.date}"
// avatar="${state.selected.committer.avatar}"
// committer
// ></commit-identity>
// `;
// $el.setAttribute('aria-hidden', 'false');
// } else {
// $el.innerHTML = '';
// $el.setAttribute('aria-hidden', 'true');
// }
// }
renderTitle(state: CommitState) {
// <header class="commit-detail-panel__header" role="banner" aria-hidden="true">
// <h1 class="commit-detail-panel__title">
// <span class="codicon codicon-git-commit commit-detail-panel__title-icon"></span>
@ -352,17 +388,17 @@ export class CommitDetailsApp extends App {
// </header>
const $el = document.querySelector<HTMLElement>('[data-region="commit-title"]');
if ($el != null) {
$el.innerHTML = this.state.selected.shortSha;
$el.innerHTML = state.selected.shortSha;
}
}
renderMessage() {
renderMessage(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="message"]');
if ($el == null) {
return;
}
const [headline, ...lines] = this.state.selected.message.split('\n');
const [headline, ...lines] = state.selected.message.split('\n');
if (lines.length > 1) {
$el.innerHTML = `<strong>${headline}</strong><br>${lines.join('<br>')}`;
} else {
@ -370,38 +406,39 @@ export class CommitDetailsApp extends App {
}
}
renderAutolinks() {
renderPullRequestAndAutolinks(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="autolinks"]');
if ($el == null) {
return;
}
const $info = document.querySelector<HTMLElement>('[data-region="rich-info"]');
if (this.state.pullRequest != null || this.state.issues?.length > 0) {
if (state.pullRequest != null || state.autolinkedIssues?.length) {
$el.setAttribute('aria-hidden', 'false');
$info?.setAttribute('aria-hidden', 'true');
this.renderPullRequest();
this.renderIssues();
this.renderPullRequest(state);
this.renderIssues(state);
} else {
$el.setAttribute('aria-hidden', 'true');
$info?.setAttribute('aria-hidden', 'false');
}
}
renderPullRequest() {
renderPullRequest(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="pull-request"]');
if ($el == null) {
return;
}
if (this.state.pullRequest != null) {
if (state.pullRequest != null) {
$el.innerHTML = `
<issue-pull-request
name="${this.state.pullRequest.title}"
url="${this.state.pullRequest.url}"
key="${this.state.pullRequest.id}"
status="${this.state.pullRequest.state}"
date="${this.state.pullRequest.date}"
name="${state.pullRequest.title}"
url="${state.pullRequest.url}"
key="${state.pullRequest.id}"
status="${state.pullRequest.state}"
date=${state.pullRequest.date}
dateFormat="${state.dateFormat}"
></issue-pull-request>
`;
$el.setAttribute('aria-hidden', 'false');
@ -411,13 +448,13 @@ export class CommitDetailsApp extends App {
}
}
renderIssues() {
renderIssues(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="issue"]');
if ($el == null) {
return;
}
if (this.state.issues?.length > 0) {
$el.innerHTML = this.state.issues
if (state.autolinkedIssues?.length) {
$el.innerHTML = state.autolinkedIssues
.map(
(issue: Record<string, any>) => `
<issue-pull-request
@ -438,4 +475,6 @@ export class CommitDetailsApp extends App {
}
}
function assertsSerialized<T>(obj: unknown): asserts obj is Serialized<T> {}
new CommitDetailsApp();

+ 2
- 2
src/webviews/apps/shared/appBase.ts 查看文件

@ -73,8 +73,8 @@ export abstract class App {
this.bindDisposables = this.onBind?.();
}
protected log(message: string) {
console.log(message);
protected log(message: string, ...optionalParams: any[]) {
console.log(message, ...optionalParams);
}
protected getState(): State {

+ 4
- 2
src/webviews/apps/shared/components/commit/commit-identity.ts 查看文件

@ -48,6 +48,9 @@ export class CommitIdentity extends LitElement {
@property()
avatar = 'https://www.gravatar.com/avatar/?s=16&d=robohash';
@property()
dateFormat = 'MMMM Do, YYYY h:mma';
@property({ type: Boolean, reflect: true })
committer = false;
@ -59,8 +62,7 @@ export class CommitIdentity extends LitElement {
/></a>
<a class="name" href="${this.email ? `mailto:${this.email}` : '#'}">${this.name}</a>
<span class="date"
>${this.committer === true ? 'committed' : 'authored'}
<formatted-date date="${this.date}"></formatted-date
>committed <formatted-date date=${this.date} dateFormat="${this.dateFormat}"></formatted-date
></span>
`;
}

+ 4
- 4
src/webviews/apps/shared/components/converters/date-converter.ts 查看文件

@ -1,12 +1,12 @@
import type { ComplexAttributeConverter } from 'lit';
export const dateConverter = (locale?: string): ComplexAttributeConverter<Date> => {
export const dateConverter = (): ComplexAttributeConverter<Date, number> => {
return {
toAttribute: (date: Date) => {
return date.toLocaleDateString(locale);
return date.getTime();
},
fromAttribute: (value: string) => {
return new Date(value);
fromAttribute: (value: string, _type?: number) => {
return new Date(parseInt(value, 10));
},
};
};

+ 1
- 1
src/webviews/apps/shared/components/formatted-date.ts 查看文件

@ -8,7 +8,7 @@ export class FormattedDate extends LitElement {
@property()
format = 'MMMM Do, YYYY h:mma';
@property({ converter: dateConverter(navigator.language), reflect: true })
@property({ converter: dateConverter(), reflect: true })
date = new Date();
override render() {

+ 456
- 207
src/webviews/commitDetails/commitDetailsWebviewView.ts 查看文件

@ -1,79 +1,159 @@
import { env, Uri, window } from 'vscode';
import type {
DiffWithPreviousCommandArgs,
DiffWithWorkingCommandArgs,
OpenFileOnRemoteCommandArgs,
} from '../../commands';
import { executeGitCommand } from '../../commands/gitCommands.actions';
import { Commands, CoreCommands } from '../../constants';
import type { CancellationToken, TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent } from 'vscode';
import { CancellationTokenSource, Disposable, env, Uri, window } from 'vscode';
import { executeGitCommand, GitActions } from '../../commands/gitCommands.actions';
import { configuration } from '../../configuration';
import { Commands } from '../../constants';
import type { Container } from '../../container';
import { GitUri } from '../../git/gitUri';
import type { GitCommit } from '../../git/models/commit';
import type { GitFileChange } from '../../git/models/file';
import { GitFile } from '../../git/models/file';
import { executeCommand, executeCoreCommand } from '../../system/command';
import type { IssueOrPullRequest } from '../../git/models/issue';
import type { PullRequest } from '../../git/models/pullRequest';
import { executeCommand } from '../../system/command';
import { debug } from '../../system/decorators/log';
import type { Deferrable } from '../../system/function';
import { debounce } from '../../system/function';
import { getSettledValue } from '../../system/promise';
import type { Serialized } from '../../system/serialize';
import { serialize } from '../../system/serialize';
import type { LinesChangeEvent } from '../../trackers/lineTracker';
import { CommitFileNode } from '../../views/nodes/commitFileNode';
import { CommitNode } from '../../views/nodes/commitNode';
import { FileRevisionAsCommitNode } from '../../views/nodes/fileRevisionAsCommitNode';
import type { ViewNode } from '../../views/nodes/viewNode';
import type { IpcMessage } from '../protocol';
import { onIpc } from '../protocol';
import { WebviewViewBase } from '../webviewViewBase';
import type { CommitDetails, CommitSummary, FileParams, RichCommitDetails, State } from './protocol';
import type { CommitDetails, FileActionParams, State } from './protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
FileComparePreviousCommandType,
FileCompareWorkingCommandType,
FileMoreActionsCommandType,
DidChangeStateNotificationType,
FileActionsCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenFileOnRemoteCommandType,
PickCommitCommandType,
RichContentNotificationType,
PinCommitCommandType,
SearchCommitCommandType,
} from './protocol';
export class CommitDetailsWebviewView extends WebviewViewBase<State> {
private commits?: GitCommit[];
private selectedCommit?: GitCommit;
private loadedOnce = false;
interface Context {
pinned: boolean;
commit: GitCommit | undefined;
richStateLoaded: boolean;
formattedMessage: string | undefined;
autolinkedIssues: IssueOrPullRequest[] | undefined;
pullRequest: PullRequest | undefined;
// commits: GitCommit[] | undefined;
}
export class CommitDetailsWebviewView extends WebviewViewBase<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 _pinned = false;
constructor(container: Container) {
super(container, 'gitlens.views.commitDetails', 'commitDetails.html', 'Commit Details');
this._context = {
pinned: false,
commit: undefined,
richStateLoaded: false,
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
};
}
override async show(options?: { commit?: GitCommit; preserveFocus?: boolean | undefined }): Promise<void> {
if (options?.commit != null) {
this.selectCommit(options.commit);
void this.refresh();
if (options != null) {
let commit;
// eslint-disable-next-line prefer-const
({ commit, ...options } = options);
if (commit != null) {
this.updateCommit(commit, { pinned: true });
}
}
return super.show(options != null ? { preserveFocus: options.preserveFocus } : undefined);
return super.show(options);
}
protected override async includeBootstrap(): Promise<Serialized<State>> {
this._bootstraping = true;
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
return this.getState(this._context);
}
private _visibilityDisposable: Disposable | undefined;
protected override onVisibilityChanged(visible: boolean) {
this.ensureTrackers();
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(true);
}
private ensureTrackers(): void {
this._visibilityDisposable?.dispose();
this._visibilityDisposable = undefined;
if (this._pinned || !this.visible) return;
const { lineTracker, commitsView } = this.container;
this._visibilityDisposable = Disposable.from(
lineTracker.subscribe(this, lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this)),
commitsView.onDidChangeVisibility(this.onCommitsViewVisibilityChanged, this),
commitsView.onDidChangeSelection(this.onCommitsViewSelectionChanged, this),
);
let commit;
const line = lineTracker.selections?.[0].active;
if (line != null) {
commit = lineTracker.getState(line)?.commit;
}
// // keep the last selected commit if the lineTracker can't find a commit
// if (commit == null && this._context.commit != null) return;
this.updateCommit(commit, { immediate: false });
}
protected override onMessageReceived(e: IpcMessage) {
switch (e.method) {
case OpenFileOnRemoteCommandType.method:
onIpc(OpenFileOnRemoteCommandType, e, params => {
this.openFileOnRemote(params);
});
onIpc(OpenFileOnRemoteCommandType, e, params => void this.openFileOnRemote(params));
break;
case OpenFileCommandType.method:
onIpc(OpenFileCommandType, e, params => {
this.openFile(params);
});
onIpc(OpenFileCommandType, e, params => void this.openFile(params));
break;
case FileCompareWorkingCommandType.method:
onIpc(FileCompareWorkingCommandType, e, params => {
this.openFileComparisonWithWorking(params);
});
case OpenFileCompareWorkingCommandType.method:
onIpc(OpenFileCompareWorkingCommandType, e, params => void this.openFileComparisonWithWorking(params));
break;
case FileComparePreviousCommandType.method:
onIpc(FileComparePreviousCommandType, e, params => {
this.openFileComparisonWithPrevious(params);
});
case OpenFileComparePreviousCommandType.method:
onIpc(
OpenFileComparePreviousCommandType,
e,
params => void this.openFileComparisonWithPrevious(params),
);
break;
case FileMoreActionsCommandType.method:
onIpc(FileMoreActionsCommandType, e, params => {
this.showFileActions(params);
});
case FileActionsCommandType.method:
onIpc(FileActionsCommandType, e, params => void this.showFileActions(params));
break;
case CommitActionsCommandType.method:
onIpc(CommitActionsCommandType, e, params => {
@ -84,159 +164,132 @@ export class CommitDetailsWebviewView extends WebviewViewBase {
case 'sha':
if (params.alt) {
this.showCommitPicker();
} else if (this.selectedCommit != null) {
void env.clipboard.writeText(this.selectedCommit.sha);
} else if (this._context.commit != null) {
void env.clipboard.writeText(this._context.commit.sha);
}
break;
}
});
break;
case PickCommitCommandType.method:
onIpc(PickCommitCommandType, e, _params => {
this.showCommitPicker();
});
onIpc(PickCommitCommandType, e, _params => this.showCommitPicker());
break;
case SearchCommitCommandType.method:
onIpc(SearchCommitCommandType, e, _params => {
this.showCommitSearch();
});
onIpc(SearchCommitCommandType, e, _params => this.showCommitSearch());
break;
case AutolinkSettingsCommandType.method:
onIpc(AutolinkSettingsCommandType, e, _params => {
this.showAutolinkSettings();
});
onIpc(AutolinkSettingsCommandType, e, _params => this.showAutolinkSettings());
break;
case PinCommitCommandType.method:
onIpc(PinCommitCommandType, e, params => this.updatePinned(params.pin ?? false, true));
break;
}
}
private getFileFromParams(params: FileParams): GitFile | undefined {
return this.selectedCommit?.files?.find(file => file.path === params.path && file.repoPath === params.repoPath);
}
private onActiveLinesChanged(e: LinesChangeEvent) {
if (e.pending) return;
private showAutolinkSettings() {
void executeCommand(Commands.ShowSettingsPageAndJumpToAutolinks);
const commit =
e.selections != null ? this.container.lineTracker.getState(e.selections[0].active)?.commit : undefined;
this.updateCommit(commit);
}
private showCommitSearch() {
void executeGitCommand({ command: 'search', state: { openPickInView: true } });
private onCommitsViewSelectionChanged(e: TreeViewSelectionChangeEvent<ViewNode>) {
const node = e.selection?.[0];
if (
node != null &&
(node instanceof CommitNode || node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode)
) {
this.updateCommit(node.commit);
}
}
private showCommitPicker() {
void executeGitCommand({
command: 'log',
state: {
reference: 'HEAD',
repo: this.selectedCommit?.repoPath,
openPickInView: true,
},
});
}
private onCommitsViewVisibilityChanged(e: TreeViewVisibilityChangeEvent) {
if (!e.visible) return;
private showCommitActions() {
if (this.selectedCommit === undefined) {
return;
const node = this.container.commitsView.activeSelection;
if (
node != null &&
(node instanceof CommitNode || node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode)
) {
this.updateCommit(node.commit);
}
void executeCommand(Commands.ShowQuickCommit, {
commit: this.selectedCommit,
});
}
private showFileActions(params: FileParams) {
const file = this.getFileFromParams(params);
if (this.selectedCommit === undefined || file === undefined) {
return;
}
const uri = GitUri.fromFile(file, this.selectedCommit.repoPath, this.selectedCommit.sha);
void executeCommand(Commands.ShowQuickCommitFile, uri, {
sha: this.selectedCommit.sha,
});
}
private _cancellationTokenSource: CancellationTokenSource | undefined = undefined;
private openFileComparisonWithWorking(params: FileParams) {
const file = this.getFileFromParams(params);
if (this.selectedCommit === undefined || file === undefined) {
return;
@debug({ args: false })
protected async getState(current: Context): Promise<Serialized<State>> {
if (this._cancellationTokenSource != null) {
this._cancellationTokenSource.cancel();
this._cancellationTokenSource.dispose();
this._cancellationTokenSource = undefined;
}
const uri = GitUri.fromFile(file, this.selectedCommit.repoPath, this.selectedCommit.sha);
void executeCommand<[Uri, DiffWithWorkingCommandArgs]>(Commands.DiffWithWorking, uri, {
showOptions: {
preserveFocus: true,
preview: true,
},
});
}
private openFileComparisonWithPrevious(params: FileParams) {
const file = this.getFileFromParams(params);
if (this.selectedCommit === undefined || file === undefined) {
return;
}
const dateFormat = configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma';
const uri = GitUri.fromFile(file, this.selectedCommit.repoPath, this.selectedCommit.sha);
const line = this.selectedCommit.lines.length ? this.selectedCommit.lines[0].line - 1 : 0;
void executeCommand<[Uri, DiffWithPreviousCommandArgs]>(Commands.DiffWithPrevious, uri, {
commit: this.selectedCommit,
line: line,
showOptions: {
preserveFocus: true,
preview: true,
...params.showOptions,
},
});
}
let details;
if (current.commit != null) {
if (!current.commit.hasFullDetails()) {
await current.commit.ensureFullDetails();
// current.commit.assertsFullDetails();
}
private openFile(params: FileParams) {
const file = this.getFileFromParams(params);
if (this.selectedCommit === undefined || file === undefined) {
return;
}
details = await this.getDetailsModel(current.commit, current.formattedMessage);
const uri = GitUri.fromFile(file, this.selectedCommit.repoPath, this.selectedCommit.sha);
void executeCoreCommand(CoreCommands.Open, uri, { background: false, preview: false });
}
if (!current.richStateLoaded) {
this._cancellationTokenSource = new CancellationTokenSource();
private openFileOnRemote(params: FileParams) {
const file = this.getFileFromParams(params);
if (this.selectedCommit === undefined || file === undefined) {
return;
const cancellation = this._cancellationTokenSource.token;
setTimeout(() => {
if (cancellation.isCancellationRequested) return;
void this.updateRichState(cancellation);
}, 100);
}
}
const uri = GitUri.fromFile(file, this.selectedCommit.repoPath, this.selectedCommit.sha);
// const commitChoices = await Promise.all(this.commits.map(async commit => summaryModel(commit)));
void executeCommand<[Uri, OpenFileOnRemoteCommandArgs]>(Commands.OpenFileOnRemote, uri, {
sha: this.selectedCommit?.sha,
const state = serialize<State>({
pinned: current.pinned,
includeRichContent: current.richStateLoaded,
// commits: commitChoices,
selected: details,
autolinkedIssues: current.autolinkedIssues,
pullRequest: current.pullRequest,
dateFormat: dateFormat,
});
return state;
}
private copyRemoteFileUrl() {}
private async updateRichState(cancellation: CancellationToken): Promise<void> {
const commit = this._context.commit;
if (commit == null) return;
private async getRichContent(selected: GitCommit): Promise<RichCommitDetails> {
const remotes = await this.container.git.getRemotesWithProviders(selected.repoPath, { sort: true });
const remotes = await this.container.git.getRemotesWithProviders(commit.repoPath, { sort: true });
const remote = await this.container.git.getBestRemoteWithRichProvider(remotes);
if (selected.message == null) {
await selected.ensureFullDetails();
}
if (cancellation.isCancellationRequested) return;
let autolinkedIssuesOrPullRequests;
let pr;
if (remote?.provider != null) {
const [autolinkedIssuesOrPullRequestsResult, prResult] = await Promise.allSettled([
this.container.autolinks.getLinkedIssuesAndPullRequests(selected.message ?? selected.summary, remote),
selected.getAssociatedPullRequest({ remote: remote }),
this.container.autolinks.getLinkedIssuesAndPullRequests(commit.message ?? commit.summary, remote),
commit.getAssociatedPullRequest({ remote: remote }),
]);
if (cancellation.isCancellationRequested) return;
autolinkedIssuesOrPullRequests = getSettledValue(autolinkedIssuesOrPullRequestsResult);
pr = getSettledValue(prResult);
}
// TODO: add HTML formatting option to linkify
const formattedMessage = this.container.autolinks.linkify(
encodeMarkup(selected.message!),
encodeMarkup(commit.message!),
true,
remote != null ? [remote] : undefined,
autolinkedIssuesOrPullRequests,
@ -247,96 +300,168 @@ export class CommitDetailsWebviewView extends WebviewViewBase {
autolinkedIssuesOrPullRequests?.delete(pr.id);
}
return {
this.updatePendingContext({
richStateLoaded: true,
formattedMessage: formattedMessage,
autolinkedIssues:
autolinkedIssuesOrPullRequests != null ? [...autolinkedIssuesOrPullRequests.values()] : undefined,
pullRequest: pr,
issues:
autolinkedIssuesOrPullRequests != null
? [...autolinkedIssuesOrPullRequests.values()].filter(<T>(i: T | undefined): i is T => i != null)
: undefined,
};
}
});
this.updateState();
private selectCommit(commit: GitCommit) {
this.commits = [commit];
this.selectedCommit = commit;
// return {
// formattedMessage: formattedMessage,
// pullRequest: pr,
// autolinkedIssues:
// autolinkedIssuesOrPullRequests != null
// ? [...autolinkedIssuesOrPullRequests.values()].filter(<T>(i: T | undefined): i is T => i != null)
// : undefined,
// };
}
@debug({ args: false })
protected async getState(includeRichContent = true): Promise<State | undefined> {
if (this.commits === undefined) {
return;
}
console.log('CommitDetailsWebview selected', this.selectedCommit);
private updateCommit(commit: GitCommit | undefined, options?: { pinned?: boolean; immediate?: boolean }) {
// this.commits = [commit];
if (this._context.commit?.sha === commit?.sha) return;
let richContent;
let formattedCommit;
if (this.selectedCommit !== undefined) {
if (includeRichContent) {
richContent = await this.getRichContent(this.selectedCommit);
}
formattedCommit = await this.getDetailsModel(this.selectedCommit, richContent?.formattedMessage);
this.updatePendingContext({
commit: commit,
richStateLoaded: Boolean(commit?.isUncommitted),
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
});
if (options?.pinned != null) {
this.updatePinned(options?.pinned);
}
const commitChoices = await Promise.all(this.commits.map(async commit => summaryModel(commit)));
this.updateState(options?.immediate ?? true);
}
return {
includeRichContent: includeRichContent,
commits: commitChoices,
selected: formattedCommit,
pullRequest: richContent?.pullRequest,
issues: richContent?.issues,
};
private updatePinned(pinned: boolean, immediate?: boolean) {
if (pinned === this._context.pinned) return;
this._pinned = pinned;
this.ensureTrackers();
this.updatePendingContext({ pinned: pinned });
this.updateState(immediate);
}
protected override async includeBootstrap() {
return window.withProgress({ location: { viewId: this.id } }, async () => {
const state = await this.getState(this.loadedOnce);
if (state === undefined) {
return {};
private updatePendingContext(context: Partial<Context>): boolean {
let changed = false;
for (const [key, value] of Object.entries(context)) {
const current = (this._context as unknown as Record<string, unknown>)[key];
if (
(current instanceof Uri || value instanceof Uri) &&
(current as any)?.toString() === value?.toString()
) {
continue;
}
if (this.loadedOnce === false) {
void this.updateRichContent();
this.loadedOnce = true;
if (current === value) {
if (value !== undefined || key in this._context) continue;
}
return state;
});
if (this._pendingContext == null) {
this._pendingContext = {};
}
(this._pendingContext as Record<string, unknown>)[key] = value;
changed = true;
}
return changed;
}
private async updateRichContent() {
if (this.selectedCommit === undefined) {
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
@debug()
private updateState(immediate: boolean = false) {
if (!this.isReady || !this.visible) return;
if (immediate) {
void this.notifyDidChangeState();
return;
}
const richContent = await this.getRichContent(this.selectedCommit);
if (richContent != null) {
void this.notify(RichContentNotificationType, richContent);
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
// if (this.commit == null) return;
// const state = await this.getState(false);
// if (state != null) {
// void this.notify(DidChangeStateNotificationType, { state: state });
// queueMicrotask(() => this.updateRichState());
// }
}
private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise<CommitDetails | undefined> {
if (commit === undefined) {
return;
}
@debug()
private async notifyDidChangeState() {
if (!this.isReady || !this.visible) return false;
this._notifyDidChangeStateDebounced?.cancel();
if (this._pendingContext == null) return false;
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
const success = await this.notify(DidChangeStateNotificationType, {
state: await this.getState(context),
});
if (success) {
this._context = context;
this._pendingContext = undefined;
}
});
}
// private async updateRichState() {
// if (this.commit == null) return;
// const richState = await this.getRichState(this.commit);
// if (richState != null) {
// void this.notify(DidChangeRichStateNotificationType, richState);
// }
// }
const authorAvatar = await commit.author?.getAvatarUri(commit);
private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise<CommitDetails> {
// if (commit == null) return undefined;
// if (!commit.hasFullDetails()) {
// await commit.ensureFullDetails();
// commit.assertsFullDetails();
// }
const authorAvatar = await commit.author.getAvatarUri(commit);
// const committerAvatar = await commit.committer?.getAvatarUri(commit);
// const formattedMessage = this.container.autolinks.linkify(
// encodeMarkup(commit.message),
// true,
// remote != null ? [remote] : undefined,
// autolinkedIssuesOrPullRequests,
// );
return {
sha: commit.sha,
shortSha: commit.shortSha,
summary: commit.summary,
message: formattedMessage ?? encodeMarkup(commit.message ?? ''),
author: { ...commit.author, avatar: authorAvatar?.toString(true) },
// summary: commit.summary,
message: formattedMessage ?? encodeMarkup(commit.message ?? commit.summary),
author: { ...commit.author, avatar: authorAvatar.toString(true) },
// committer: { ...commit.committer, avatar: committerAvatar?.toString(true) },
files: commit.files?.map(({ repoPath, path, status }) => {
files: commit.files?.map(({ status, repoPath, path, originalPath }) => {
const icon = GitFile.getStatusIcon(status);
return {
repoPath: repoPath,
path: path,
originalPath: originalPath,
status: status,
repoPath: repoPath,
icon: {
dark: this._view!.webview.asWebviewUri(
Uri.joinPath(this.container.context.extensionUri, 'images', 'dark', icon),
@ -350,19 +475,143 @@ export class CommitDetailsWebviewView extends WebviewViewBase {
stats: commit.stats,
};
}
}
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),
};
private async getFileFromParams(params: FileActionParams): Promise<GitFileChange | undefined> {
return this._context.commit?.findFile(params.path);
}
private showAutolinkSettings() {
void executeCommand(Commands.ShowSettingsPageAndJumpToAutolinks);
}
private showCommitSearch() {
void executeGitCommand({ command: 'search', state: { openPickInView: true } });
}
private showCommitPicker() {
void executeGitCommand({
command: 'log',
state: {
reference: 'HEAD',
repo: this._context.commit?.repoPath,
openPickInView: true,
},
});
}
private showCommitActions() {
if (this._context.commit == null) return;
void GitActions.Commit.showDetailsQuickPick(this._context.commit);
// void executeCommand(Commands.ShowQuickCommit, {
// commit: this._context.commit,
// });
}
private async showFileActions(params: FileActionParams) {
if (this._context.commit == null) return;
const file = await this.getFileFromParams(params);
if (file == null) return;
void GitActions.Commit.showDetailsQuickPick(this._context.commit, file);
// const uri = GitUri.fromFile(file, this._context.commit.repoPath, this._context.commit.sha);
// void executeCommand(Commands.ShowQuickCommitFile, uri, {
// sha: this._context.commit.sha,
// });
}
private async openFileComparisonWithWorking(params: FileActionParams) {
if (this._context.commit == null) return;
const file = await this.getFileFromParams(params);
if (file == null) return;
void GitActions.Commit.openChangesWithWorking(file.path, this._context.commit, {
preserveFocus: true,
preview: true,
...params.showOptions,
});
// const uri = GitUri.fromFile(file, this._context.commit.repoPath, this._context.commit.sha);
// void executeCommand<[Uri, DiffWithWorkingCommandArgs]>(Commands.DiffWithWorking, uri, {
// showOptions: {
// preserveFocus: true,
// preview: true,
// },
// });
}
private async openFileComparisonWithPrevious(params: FileActionParams) {
if (this._context.commit == null) return;
const file = await this.getFileFromParams(params);
if (file == null) return;
void GitActions.Commit.openChanges(file.path, this._context.commit, {
preserveFocus: true,
preview: true,
...params.showOptions,
});
// const uri = GitUri.fromFile(file, this._context.commit.repoPath, this._context.commit.sha);
// const line = this._context.commit.lines.length ? this._context.commit.lines[0].line - 1 : 0;
// void executeCommand<[Uri, DiffWithPreviousCommandArgs]>(Commands.DiffWithPrevious, uri, {
// commit: this._context.commit,
// line: line,
// showOptions: {
// preserveFocus: true,
// preview: true,
// ...params.showOptions,
// },
// });
}
private async openFile(params: FileActionParams) {
if (this._context.commit == null) return;
const file = await this.getFileFromParams(params);
if (file == null) return;
void GitActions.Commit.openFile(file.path, this._context.commit, {
preserveFocus: true,
preview: true,
...params.showOptions,
});
// const uri = GitUri.fromFile(file, this.commit.repoPath, this.commit.sha);
// void executeCoreCommand(CoreCommands.Open, uri, { background: false, preview: false });
}
private async openFileOnRemote(params: FileActionParams) {
if (this._context.commit == null) return;
const file = await this.getFileFromParams(params);
if (file == null) return;
void GitActions.Commit.openFileOnRemote(file.path, this._context.commit);
// const uri = GitUri.fromFile(file, this.commit.repoPath, this.commit.sha);
// void executeCommand<[Uri, OpenFileOnRemoteCommandArgs]>(Commands.OpenFileOnRemote, uri, {
// sha: this.commit?.sha,
// });
}
}
// 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),
// };
// }
function encodeMarkup(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

+ 51
- 32
src/webviews/commitDetails/protocol.ts 查看文件

@ -1,63 +1,82 @@
import type { TextDocumentShowOptions } from 'vscode';
import type { GitCommitIdentityShape, GitCommitStats } from '../../git/models/commit';
import type { GitFileChangeShape } from '../../git/models/file';
import type { IssueOrPullRequest } from '../../git/models/issue';
import type { PullRequest } from '../../git/models/pullRequest';
import type { PullRequestShape } from '../../git/models/pullRequest';
import type { Serialized } from '../../system/serialize';
import { IpcCommandType, IpcNotificationType } from '../protocol';
export type FileShowOptions = TextDocumentShowOptions;
export type CommitSummary = {
sha: string;
summary: string;
message?: string;
author: Record<string, any>;
shortSha: string;
avatar?: string;
// summary: string;
message: string;
author: GitCommitIdentityShape & { avatar: string | undefined };
// committer: GitCommitIdentityShape & { avatar: string | undefined };
};
export type CommitDetails = {
committer?: Record<string, any>;
files?: Record<string, any>[];
stats?: Record<string, any>;
} & CommitSummary;
export type RichCommitDetails = {
formattedMessage?: string;
pullRequest?: PullRequest;
issues?: IssueOrPullRequest[];
export type CommitDetails = CommitSummary & {
files?: (GitFileChangeShape & { icon: { dark: string; light: string } })[];
stats?: GitCommitStats;
};
export type State = {
commits?: CommitSummary[];
} & Record<string, any>;
pinned: boolean;
// commits?: CommitSummary[];
includeRichContent?: boolean;
selected?: CommitDetails;
autolinkedIssues?: IssueOrPullRequest[];
pullRequest?: PullRequestShape;
dateFormat: string;
};
export type ShowCommitDetailsViewCommandArgs = string[];
// COMMANDS
export interface FileParams {
export interface CommitActionsParams {
action: 'sha' | 'more';
alt?: boolean;
}
export const CommitActionsCommandType = new IpcCommandType<CommitActionsParams>('commit/actions');
export interface FileActionParams {
path: string;
repoPath: string;
showOptions?: TextDocumentShowOptions;
}
export const FileActionsCommandType = new IpcCommandType<FileActionParams>('commit/file/actions');
export const OpenFileCommandType = new IpcCommandType<FileActionParams>('commit/file/open');
export const OpenFileOnRemoteCommandType = new IpcCommandType<FileActionParams>('commit/file/openOnRemote');
export const OpenFileCompareWorkingCommandType = new IpcCommandType<FileActionParams>('commit/file/compareWorking');
export const OpenFileComparePreviousCommandType = new IpcCommandType<FileActionParams>('commit/file/comparePrevious');
export interface ActionsParams {
action: 'sha' | 'more';
alt?: boolean;
}
export const OpenFileOnRemoteCommandType = new IpcCommandType<FileParams>('commit/file/openOnRemote');
export const OpenFileCommandType = new IpcCommandType<FileParams>('commit/file/open');
export const FileCompareWorkingCommandType = new IpcCommandType<FileParams>('commit/file/compareWorking');
export const FileComparePreviousCommandType = new IpcCommandType<FileParams>('commit/file/comparePrevious');
export const FileMoreActionsCommandType = new IpcCommandType<FileParams>('commit/file/moreActions');
export const CommitActionsCommandType = new IpcCommandType<ActionsParams>('commit/actions');
export const PickCommitCommandType = new IpcCommandType<undefined>('commit/pickCommit');
export const SearchCommitCommandType = new IpcCommandType<undefined>('commit/searchCommit');
export const AutolinkSettingsCommandType = new IpcCommandType<undefined>('commit/autolinkSettings');
export interface PinParams {
pin: boolean;
}
export const PinCommitCommandType = new IpcCommandType<PinParams>('commit/pin');
// NOTIFICATIONS
export interface DidChangeParams {
state: State;
export interface DidChangeStateParams {
state: Serialized<State>;
}
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('commit/didChange');
export const DidChangeStateNotificationType = new IpcNotificationType<DidChangeStateParams>('commit/didChange');
export const RichContentNotificationType = new IpcNotificationType<RichCommitDetails>('commit/richContent');
export type DidChangeRichStateParams = {
formattedMessage?: string;
autolinkedIssues?: IssueOrPullRequest[];
pullRequest?: PullRequestShape;
};
export const DidChangeRichStateNotificationType = new IpcNotificationType<DidChangeRichStateParams>(
'commit/didChange/rich',
);

+ 4
- 4
src/webviews/webviewViewBase.ts 查看文件

@ -29,7 +29,7 @@ function nextIpcId() {
return `host:${ipcSequence}`;
}
export abstract class WebviewViewBase<State> implements WebviewViewProvider, Disposable {
export abstract class WebviewViewBase<State, SerializedState = State> implements WebviewViewProvider, Disposable {
protected readonly disposables: Disposable[] = [];
protected isReady: boolean = false;
private _disposableView: Disposable | undefined;
@ -93,7 +93,7 @@ export abstract class WebviewViewBase implements WebviewViewProvider, Dis
protected registerCommands?(): Disposable[];
protected includeBootstrap?(): State | Promise<State>;
protected includeBootstrap?(): SerializedState | Promise<SerializedState>;
protected includeHead?(): string | Promise<string>;
protected includeBody?(): string | Promise<string>;
protected includeEndOfBody?(): string | Promise<string>;
@ -201,7 +201,7 @@ export abstract class WebviewViewBase implements WebviewViewProvider, Dis
const html = content.replace(
/#{(head|body|endOfBody|placement|cspSource|cspNonce|root|webroot)}/g,
(_substring, token) => {
(_substring: string, token: string) => {
switch (token) {
case 'head':
return head ?? '';
@ -238,7 +238,7 @@ export abstract class WebviewViewBase implements WebviewViewProvider, Dis
return this.postMessage({ id: nextIpcId(), method: type.method, params: params });
}
private postMessage(message: IpcMessage) {
protected postMessage(message: IpcMessage) {
if (this._view == null) return Promise.resolve(false);
return this._view.webview.postMessage(message);

||||||
x
 
000:0
正在加载...
取消
保存