637 righe
20 KiB

/*global*/
import type { HierarchicalItem } from '../../../system/array';
import { makeHierarchical } from '../../../system/array';
import type { Serialized } from '../../../system/serialize';
import type { IpcMessage } from '../../../webviews/protocol';
import { onIpc } from '../../../webviews/protocol';
import type { CommitActionsParams, State } from '../../commitDetails/protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
FileActionsCommandType,
messageHeadlineSplitterToken,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenFileOnRemoteCommandType,
PickCommitCommandType,
PinCommitCommandType,
PreferencesCommandType,
SearchCommitCommandType,
} from '../../commitDetails/protocol';
import { App } from '../shared/appBase';
import type { FileChangeListItem, FileChangeListItemDetail } from '../shared/components/list/file-change-list-item';
import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../shared/components/webview-pane';
import { DOM } from '../shared/dom';
import './commitDetails.scss';
import '../shared/components/code-icon';
import '../shared/components/commit/commit-identity';
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/webview-pane';
import '../shared/components/progress';
import '../shared/components/list/list-container';
import '../shared/components/list/list-item';
import '../shared/components/list/file-change-list-item';
import '../shared/components/actions/action-item';
import '../shared/components/actions/action-nav';
const uncommittedSha = '0000000000000000000000000000000000000000';
type CommitState = SomeNonNullable<Serialized<State>, 'selected'>;
export class CommitDetailsApp extends App<Serialized<State>> {
constructor() {
super('CommitDetailsApp');
}
override onInitialize() {
this.log(`${this.appName}.onInitialize`);
this.renderContent();
}
override onBind() {
const disposables = [
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-open-on-remote', e =>
this.onOpenFileOnRemote(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-open', e =>
this.onOpenFile(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-compare-working', e =>
this.onCompareFileWithWorking(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-compare-previous', e =>
this.onCompareFileWithPrevious(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-more-actions', e =>
this.onFileMoreActions(e.detail),
),
DOM.on('[data-action="dismiss-banner"]', 'click', e => this.onDismissBanner(e)),
DOM.on('[data-action="commit-actions"]', 'click', e => this.onCommitActions(e)),
DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)),
DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)),
DOM.on('[data-action="autolink-settings"]', 'click', e => this.onAutolinkSettings(e)),
DOM.on('[data-switch-value]', 'click', e => this.onTreeSetting(e)),
DOM.on('[data-action="pin"]', 'click', e => this.onTogglePin(e)),
DOM.on<WebviewPane, WebviewPaneExpandedChangeEventDetail>(
'[data-region="rich-pane"]',
'expanded-change',
e => this.onExpandedChange(e.detail),
),
];
return disposables;
}
protected override onMessageReceived(e: MessageEvent) {
const msg = e.data as IpcMessage;
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`);
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 DidChangeNotificationType.method:
onIpc(DidChangeNotificationType, msg, params => {
assertsSerialized<typeof params.state>(params.state);
this.state = params.state;
this.renderContent();
});
break;
default:
super.onMessageReceived?.(e);
}
}
onDismissBanner(e: MouseEvent) {
const dismissed = this.state.preferences?.dismissed ?? [];
if (dismissed.includes('sidebar')) {
return;
}
dismissed.push('sidebar');
this.state.preferences = { ...this.state.preferences, dismissed: dismissed };
const parent = (e.target as HTMLElement)?.closest<HTMLElement>('[data-region="sidebar-banner"]') ?? undefined;
this.renderBanner(this.state as CommitState, parent);
this.sendCommand(PreferencesCommandType, { dismissed: dismissed });
}
private onTreeSetting(e: MouseEvent) {
const isTree = (e.target as HTMLElement)?.getAttribute('data-switch-value') === 'list-tree';
if (!isTree === this.state.preferences?.filesAsTree) return;
this.state.preferences = { ...this.state.preferences, filesAsTree: !isTree };
this.renderFiles(this.state as CommitState);
this.sendCommand(PreferencesCommandType, { filesAsTree: !isTree });
}
private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail) {
this.sendCommand(PreferencesCommandType, { autolinksExpanded: e.expanded });
}
private onTogglePin(e: MouseEvent) {
e.preventDefault();
this.sendCommand(PinCommitCommandType, { pin: !this.state.pinned });
}
private onAutolinkSettings(e: MouseEvent) {
e.preventDefault();
this.sendCommand(AutolinkSettingsCommandType, undefined);
}
private onSearchCommit(_e: MouseEvent) {
this.sendCommand(SearchCommitCommandType, undefined);
}
private onPickCommit(_e: MouseEvent) {
this.sendCommand(PickCommitCommandType, undefined);
}
private onOpenFileOnRemote(e: FileChangeListItemDetail) {
this.sendCommand(OpenFileOnRemoteCommandType, e);
}
private onOpenFile(e: FileChangeListItemDetail) {
this.sendCommand(OpenFileCommandType, e);
}
private onCompareFileWithWorking(e: FileChangeListItemDetail) {
this.sendCommand(OpenFileCompareWorkingCommandType, e);
}
private onCompareFileWithPrevious(e: FileChangeListItemDetail) {
this.sendCommand(OpenFileComparePreviousCommandType, e);
}
private onFileMoreActions(e: FileChangeListItemDetail) {
this.sendCommand(FileActionsCommandType, e);
}
private onCommitActions(e: MouseEvent) {
e.preventDefault();
if (this.state.selected === undefined) {
e.stopPropagation();
return;
}
const action = (e.target as HTMLElement)?.getAttribute('data-action-type');
if (action == null) return;
this.sendCommand(CommitActionsCommandType, { action: action as CommitActionsParams['action'], alt: e.altKey });
}
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');
$main?.setAttribute('aria-hidden', hasSelection ? 'false' : 'true');
return hasSelection;
}
renderRichContent() {
if (!this.renderCommit(this.state)) return;
this.renderMessage(this.state);
this.renderPullRequestAndAutolinks(this.state);
}
renderContent() {
if (!this.renderCommit(this.state)) return;
this.renderBanner(this.state);
this.renderActions(this.state);
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.renderPullRequestAndAutolinks(this.state);
// }
}
renderBanner(state: CommitState, target?: HTMLElement) {
if (!state.preferences?.dismissed?.includes('sidebar')) {
return;
}
if (!target) {
target = document.querySelector<HTMLElement>('[data-region="sidebar-banner"]') ?? undefined;
}
target?.remove();
}
renderActions(state: CommitState) {
const isHiddenForUncommitted = state.selected?.sha === uncommittedSha ? 'true' : 'false';
for (const $el of document.querySelectorAll('[data-action-type="graph"],[data-action-type="more"]')) {
$el.setAttribute('aria-hidden', isHiddenForUncommitted);
}
const isHiddenForCommitted = state.selected?.sha !== uncommittedSha ? 'true' : 'false';
for (const $el of document.querySelectorAll('[data-action-type="scm"]')) {
$el.setAttribute('aria-hidden', isHiddenForCommitted);
}
}
renderPin(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-action="pin"]');
if ($el == null) return;
const label = state.pinned ? 'Unpin this Commit' : 'Pin this Commit';
$el.setAttribute('aria-label', label);
$el.setAttribute('title', label);
$el.classList.toggle('is-active', state.pinned);
const $icon = $el.querySelector('[data-region="commit-pin"]');
$icon?.setAttribute('icon', state.pinned ? 'gl-pinned-filled' : 'pin');
}
renderSha(state: CommitState) {
const $els = [...document.querySelectorAll<HTMLElement>('[data-region="shortsha"]')];
if ($els.length === 0) return;
for (const $el of $els) {
$el.textContent = state.selected.shortSha;
}
}
renderChoices() {
// <nav class="commit-detail-panel__nav" aria-label="list of selected commits" data-region="choices">
// <p class="commit-detail-panel__commit-count">
// Selected commits: <span data-region="choices-count">2</span>
// </p>
// <ul class="commit-detail-panel__commits" data-region="choices-list">
// <li class="commit-detail-panel__commit">
// <skeleton-loader></skeleton-loader>
// </li>
// <li class="commit-detail-panel__commit">
// <skeleton-loader></skeleton-loader>
// </li>
// </ul>
// </nav>
const $el = document.querySelector<HTMLElement>('[data-region="choices"]');
if ($el == null) 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 = '';
// }
}
renderStats(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="stats"]');
if ($el == null) return;
if (state.selected.stats?.changedFiles == null) {
$el.innerHTML = '';
return;
}
if (typeof state.selected.stats.changedFiles === 'number') {
$el.innerHTML = /*html*/ `
<commit-stats added="?" modified="${state.selected.stats.changedFiles}" removed="?"></commit-stats>
`;
} else {
const { added, deleted, changed } = state.selected.stats.changedFiles;
$el.innerHTML = /*html*/ `
<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>
`;
}
}
renderFiles(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="files"]');
if ($el == null) return;
const isTree = state.preferences?.filesAsTree === true;
const $toggle = document.querySelector('[data-switch-value]');
if ($toggle) {
$toggle.setAttribute('data-switch-value', isTree ? 'list-tree' : 'list');
$toggle.setAttribute('icon', isTree ? 'list-flat' : 'list-tree');
$toggle.setAttribute('label', isTree ? 'View as List' : 'View as Tree');
}
if (!state.selected.files?.length) {
$el.innerHTML = '';
return;
}
const stashAttr = state.selected.isStash
? 'stash '
: state.selected.sha === uncommittedSha
? 'uncommitted '
: '';
if (isTree) {
const tree = makeHierarchical(
state.selected.files,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
true,
);
const flatTree = flattenHeirarchy(tree);
$el.innerHTML = `
<li class="change-list__item">
<list-container class="indentGuides-${state.indentGuides}">
${flatTree
.map(({ level, item }) => {
if (item.name === '') {
return '';
}
if (item.value == null) {
return /*html*/ `
<list-item level="${level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return /*html*/ `
<file-change-list-item
tree
level="${level}"
${stashAttr}
path="${item.value.path}"
repo="${item.value.repoPath}"
icon="${item.value.icon.dark}"
status="${item.value.status}"
></file-change-list-item>
`;
})
.join('')}
</list-container>
</li>`;
} else {
$el.innerHTML = /*html*/ `
<li class="change-list__item">
<list-container>
${state.selected.files
.map(
(file: Record<string, any>) => /*html*/ `
<file-change-list-item
${stashAttr}
path="${file.path}"
repo="${file.repoPath}"
icon="${file.icon.dark}"
status="${file.status}"
></file-change-list-item>
`,
)
.join('')}
</list-container>
</li>`;
}
$el.setAttribute('aria-hidden', 'false');
}
renderAuthor(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="author"]');
if ($el == null) return;
if (state.selected?.isStash === true) {
$el.innerHTML = /*html*/ `
<div class="commit-stashed">
<span class="commit-stashed__media"><code-icon class="commit-stashed__icon" icon="inbox"></code-icon></span>
<span class="commit-stashed__date">stashed <formatted-date date=${state.selected.author.date} dateFormat="${state.dateFormat}"></formatted-date></span>
</div>
`;
$el.setAttribute('aria-hidden', 'false');
} else if (state.selected?.author != null) {
$el.innerHTML = /*html*/ `
<commit-identity
name="${state.selected.author.name}"
email="${state.selected.author.email}"
date=${state.selected.author.date}
dateFormat="${state.dateFormat}"
avatar="${state.selected.author.avatar}"
actionLabel="${state.selected.sha === uncommittedSha ? 'modified' : 'committed'}"
></commit-identity>
`;
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
// 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>
// Commit: <span data-region="commit-title"></span>
// </h1>
// </header>
const $el = document.querySelector<HTMLElement>('[data-region="commit-title"]');
if ($el == null) return;
$el.innerHTML = state.selected.shortSha;
}
renderMessage(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="message"]');
if ($el == null) return;
const index = state.selected.message.indexOf(messageHeadlineSplitterToken);
if (index === -1) {
$el.innerHTML = /*html*/ `<strong>${state.selected.message}</strong>`;
} else {
$el.innerHTML = /*html*/ `<strong>${state.selected.message.substring(
0,
index,
)}</strong><br />${state.selected.message.substring(index + 3)}`;
}
}
renderPullRequestAndAutolinks(state: CommitState) {
const $el = document.querySelector<WebviewPane>('[data-region="rich-pane"]');
if ($el == null) return;
$el.expanded = this.state.preferences?.autolinksExpanded ?? true;
$el.loading = !state.includeRichContent;
const $info = $el.querySelector('[data-region="rich-info"]');
const $autolinks = $el.querySelector('[data-region="autolinks"]');
if (state.pullRequest != null || state.autolinkedIssues?.length) {
$autolinks?.setAttribute('aria-hidden', 'false');
$info?.setAttribute('aria-hidden', 'true');
this.renderPullRequest(state);
this.renderIssues(state);
} else {
$autolinks?.setAttribute('aria-hidden', 'true');
$info?.setAttribute('aria-hidden', 'false');
}
const $count = $el.querySelector('[data-region="autolink-count"]');
if ($count == null) return;
const count = (state.pullRequest != null ? 1 : 0) + (state.autolinkedIssues?.length ?? 0);
$count.innerHTML = state.includeRichContent ? `${count} found` : '…';
}
renderPullRequest(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="pull-request"]');
if ($el == null) return;
if (state.pullRequest != null) {
$el.innerHTML = /*html*/ `
<issue-pull-request
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');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
renderIssues(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="issue"]');
if ($el == null) return;
if (state.autolinkedIssues?.length) {
$el.innerHTML = state.autolinkedIssues
.map(
issue => /*html*/ `
<issue-pull-request
name="${issue.title}"
url="${issue.url}"
key="${issue.id}"
status="${issue.closed ? 'closed' : 'opened'}"
date="${issue.closed ? issue.closedDate : issue.date}"
></issue-pull-request>
`,
)
.join('');
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
}
function assertsSerialized<T>(obj: unknown): asserts obj is Serialized<T> {}
function flattenHeirarchy<T>(item: HierarchicalItem<T>, level = 0): { level: number; item: HierarchicalItem<T> }[] {
const flattened: { level: number; item: HierarchicalItem<T> }[] = [];
if (item == null) return flattened;
flattened.push({ level: level, item: item });
if (item.children != null) {
const children = Array.from(item.children.values());
children.sort((a, b) => {
if (!a.value || !b.value) {
return (a.value ? 1 : -1) - (b.value ? 1 : -1);
}
if (a.relativePath < b.relativePath) {
return -1;
}
if (a.relativePath > b.relativePath) {
return 1;
}
return 0;
});
children.forEach(child => {
flattened.push(...flattenHeirarchy(child, level + 1));
});
}
return flattened;
}
new CommitDetailsApp();