- adds lit components - exposes webview in WebviewViewBase - adds pointer cursor to enabled buttons - adds custom events support to DOM utilitymain
@ -1,8 +1,11 @@ | |||
{ | |||
"extends": ["../../../.eslintrc.base.json"], | |||
"extends": ["../../../.eslintrc.base.json", "plugin:lit/recommended"], | |||
"env": { | |||
"browser": true | |||
}, | |||
"rules": { | |||
"import/extensions": ["error", "never", { "js": "always" }] | |||
}, | |||
"parserOptions": { | |||
"project": "src/webviews/apps/tsconfig.json" | |||
} | |||
@ -0,0 +1,94 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
</head> | |||
<body class="preload"> | |||
<div class="commit-detail-panel"> | |||
<!-- 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> | |||
<button class="button button--full" type="button" data-action="pick-commit">Pick Commit...</button> | |||
</p> | |||
<!-- <p><button class="button button--full" type="button">Follow Open Files</button></p> --> | |||
</div> | |||
<main class="commit-details commit-detail-panel__main" id="main" tabindex="-1"> | |||
<div class="commit-details__commit"> | |||
<div class="commit-details__top"> | |||
<ul class="commit-details__authors" aria-label="Authors"> | |||
<li class="commit-details__author" data-region="author"> | |||
<skeleton-loader></skeleton-loader> | |||
</li> | |||
</ul> | |||
<a | |||
class="commit-details__commit-action" | |||
href="#" | |||
data-action="commit-show-actions" | |||
aria-label="Show Commit Actions" | |||
title="Show Commit Actions" | |||
><code-icon icon="kebab-vertical"></code-icon | |||
></a> | |||
</div> | |||
<div class="commit-details__message"> | |||
<p class="commit-details__message-text" data-region="message"> | |||
<skeleton-loader></skeleton-loader> | |||
</p> | |||
</div> | |||
</div> | |||
<webview-pane collapsable expanded data-region="rich-pane"> | |||
<span slot="title">Autolinks</span> | |||
<div class="commit-details__rich" data-region="rich-info" aria-hidden="true"> | |||
<p> | |||
<code-icon icon="info"></code-icon> Use autolinks to identify external references, like Jira | |||
issues or Zendesk tickets, in your commit messages and convert them to clickable links. | |||
</p> | |||
<p> | |||
<a href="#">Configure autolinks in settings</a> | |||
</p> | |||
</div> | |||
<div class="commit-details__rich" data-region="autolinks"> | |||
<section | |||
class="commit-details__pull-request" | |||
aria-label="Pull request" | |||
data-region="pull-request" | |||
> | |||
<skeleton-loader lines="2"></skeleton-loader> | |||
</section> | |||
<section class="commit-details__issue" aria-label="Issue" data-region="issue"> | |||
<skeleton-loader lines="2"></skeleton-loader> | |||
</section> | |||
</div> | |||
</webview-pane> | |||
<webview-pane class="commit-details__files" collapsable expanded> | |||
<span slot="title" | |||
>Files changed | |||
<span class="commit-details__stats" data-region="stats"></span> | |||
</span> | |||
<ul class="change-list" data-region="files"> | |||
<li class="change-list__item commit-details__item-skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</li> | |||
<li class="change-list__item commit-details__item-skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</li> | |||
<li class="change-list__item commit-details__item-skeleton"> | |||
<skeleton-loader></skeleton-loader> | |||
</li> | |||
</ul> | |||
</webview-pane> | |||
</main> | |||
</div> | |||
#{endOfBody} | |||
<style nonce="#{cspNonce}"> | |||
@font-face { | |||
font-family: 'codicon'; | |||
src: url('#{webroot}/codicon.ttf?404cbc4fe3a64b9a93064eef76704c79') format('truetype'); | |||
} | |||
</style> | |||
</body> | |||
</html> |
@ -0,0 +1,342 @@ | |||
// @import '../shared/base'; | |||
// @import '../shared/buttons'; | |||
// @import '../shared/icons'; | |||
:root { | |||
--gitlens-gutter-width: 20px; | |||
--gitlens-scrollbar-gutter-width: 10px; | |||
--gitlens-view-background-color: #fafafa; | |||
} | |||
// generic resets | |||
html { | |||
// height: 100%; | |||
font-size: 62.5%; | |||
box-sizing: border-box; | |||
font-family: var(--font-family); | |||
} | |||
* { | |||
&, | |||
&::before, | |||
&::after { | |||
box-sizing: inherit; | |||
} | |||
} | |||
body { | |||
// height: 100%; | |||
font-family: var(--font-family); | |||
font-size: var(--font-size); | |||
color: var(--color-foreground); | |||
padding: 0; | |||
} | |||
ul { | |||
list-style: none; | |||
margin: 0; | |||
padding: 0; | |||
} | |||
::-webkit-scrollbar-corner { | |||
background-color: transparent !important; | |||
} | |||
.button { | |||
--button-foreground: var(--vscode-button-foreground); | |||
--button-background: var(--vscode-button-background); | |||
--button-hover-background: var(--vscode-button-hoverBackground); | |||
display: inline-block; | |||
border: none; | |||
padding: 0.4rem; | |||
font-size: inherit; | |||
line-height: inherit; | |||
text-align: center; | |||
text-decoration: none; | |||
user-select: none; | |||
background: var(--button-background); | |||
color: var(--button-foreground); | |||
cursor: pointer; | |||
&:hover { | |||
background: var(--button-hover-background); | |||
} | |||
&:focus { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: 0.2rem; | |||
} | |||
&--full { | |||
width: 100%; | |||
} | |||
} | |||
// webview-specific styles | |||
.change-list { | |||
list-style: none; | |||
&__item { | |||
// & + & { | |||
// margin-top: 0.25rem; | |||
// } | |||
} | |||
&__link { | |||
width: 100%; | |||
color: inherit; | |||
white-space: nowrap; | |||
text-overflow: ellipsis; | |||
overflow: hidden; | |||
} | |||
&__type {} | |||
&__filename {} | |||
&__path {} | |||
&__actions { | |||
flex: none; | |||
} | |||
&__action {} | |||
} | |||
.pull-request, | |||
.issue { | |||
display: grid; | |||
gap: 0.25rem 0.5rem; | |||
justify-content: start; | |||
&__icon { | |||
grid-column: 1; | |||
grid-row: 1 / 3; | |||
color: var(--vscode-gitlens-mergedPullRequestIconColor); | |||
} | |||
&__title { | |||
grid-column: 2; | |||
grid-row: 1; | |||
margin: 0; | |||
font-size: 1.5rem; | |||
} | |||
&__date { | |||
grid-column: 2; | |||
grid-row: 2; | |||
margin: 0; | |||
font-size: 1.2rem; | |||
} | |||
} | |||
.commit-author { | |||
display: grid; | |||
gap: 0.25rem 0.5rem; | |||
justify-content: start; | |||
&__avatar { | |||
grid-column: 1; | |||
grid-row: 1 / 3; | |||
} | |||
&__name { | |||
grid-column: 2; | |||
grid-row: 1; | |||
font-size: 1.5rem; | |||
} | |||
&__date { | |||
grid-column: 2; | |||
grid-row: 2; | |||
font-size: 1.2rem; | |||
} | |||
} | |||
.commit-details { | |||
&__commit { | |||
padding: { // 1.5rem | |||
left: var( --gitlens-gutter-width); | |||
right: var( --gitlens-scrollbar-gutter-width); | |||
} | |||
// background-color: var(--color-background--lighten-05); | |||
margin-bottom: 1.75rem; | |||
display: flex; | |||
flex-direction: column; | |||
gap: 1.5rem; | |||
} | |||
&__top { | |||
display: flex; | |||
flex-direction: row; | |||
align-items: flex-start; | |||
justify-content: space-between; | |||
gap: 0.5rem; | |||
} | |||
&__message { | |||
font-size: 1.5rem; | |||
border: 1px solid var(--color-background--lighten-15); | |||
padding: 0.5rem; | |||
} | |||
&__message-text { | |||
flex: 1; | |||
margin: 0; | |||
display: block; | |||
@supports (-webkit-line-clamp: 6) { | |||
display: -webkit-box; | |||
-webkit-line-clamp: 6; | |||
-webkit-box-orient: vertical; | |||
overflow: hidden; | |||
} | |||
} | |||
&__commit-action { | |||
display: inline-flex; | |||
justify-content: center; | |||
align-items: center; | |||
width: 20px; | |||
height: 20px; | |||
border-radius: 0.25em; | |||
color: inherit; | |||
padding: 2px; | |||
vertical-align: text-bottom; | |||
text-decoration: none; | |||
> * { | |||
pointer-events: none; | |||
} | |||
&:hover { | |||
color: var(--vscode-foreground); | |||
background-color: var(--color-background--lighten-15); | |||
} | |||
} | |||
&__authors { | |||
flex-basis: 100%; | |||
} | |||
&__author { | |||
& + & { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
&__rich { | |||
padding: 0.5rem var( --gitlens-scrollbar-gutter-width) 1rem var( --gitlens-gutter-width); | |||
> :last-child { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
&__pull-request {} | |||
&__issue { | |||
> :not(:first-child) { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
&__stats { | |||
margin-left: 0.5rem; | |||
} | |||
// &__files { | |||
// border-top: 1px solid var(--color-background--lighten-075); | |||
// padding: { | |||
// top: 1.75rem; | |||
// } | |||
// } | |||
&__file { | |||
padding: { | |||
left: var( --gitlens-gutter-width); | |||
right: var( --gitlens-scrollbar-gutter-width); | |||
top: 1px; | |||
bottom: 1px; | |||
} | |||
} | |||
&__item-skeleton { | |||
padding: { | |||
left: var( --gitlens-gutter-width); | |||
right: var( --gitlens-scrollbar-gutter-width); | |||
top: 1px; | |||
bottom: 1px; | |||
} | |||
} | |||
} | |||
.commit-detail-panel { | |||
$block: &; | |||
max-height: 100vh; | |||
overflow: auto; | |||
scrollbar-gutter: stable; | |||
[aria-hidden="true"] { | |||
display: none; | |||
} | |||
&__none { | |||
padding: { | |||
left: var( --gitlens-gutter-width); | |||
right: var( --gitlens-scrollbar-gutter-width); | |||
} | |||
} | |||
&__header { | |||
margin: { | |||
top: 1rem; | |||
bottom: 1.5rem; | |||
} | |||
} | |||
&__title { | |||
font-size: 2.4rem; | |||
// FIXME: specificity hack | |||
&-icon { | |||
// color: var(--vscode-banner-iconForeground); | |||
font-size: inherit !important; | |||
} | |||
} | |||
&__nav { | |||
border: 1px solid var(--color-button-secondary-background); | |||
padding: 0.5rem; | |||
margin: { | |||
top: 1rem; | |||
bottom: 1.5rem; | |||
} | |||
} | |||
&__commit-count { | |||
margin: { | |||
top: 0; | |||
bottom: 0.5rem; | |||
} | |||
} | |||
&__commits {} | |||
&__commit { | |||
& + & { | |||
margin-top: 0.5rem; | |||
} | |||
} | |||
&__commit-button { | |||
appearance: none; | |||
text-decoration: none; | |||
border: none; | |||
color: var(--color-button-foreground); | |||
background-color: var(--color-button-secondary-background); | |||
padding: 0.5rem; | |||
display: flex; | |||
align-items: center; | |||
width: 100%; | |||
gap: 0.5rem; | |||
> :last-child { | |||
margin-left: auto; | |||
} | |||
&[aria-current="true"] { | |||
background-color: var(--color-button-background); | |||
} | |||
} | |||
&__main { | |||
padding: { | |||
top: 1rem; | |||
bottom: 1rem; | |||
} | |||
} | |||
} | |||
@import '../shared/codicons'; | |||
@import '../shared/utils'; |
@ -0,0 +1,392 @@ | |||
/*global*/ | |||
import { IpcMessage, onIpc } from '../../../webviews/protocol'; | |||
import { | |||
CommitActionsCommandType, | |||
CommitSummary, | |||
DidChangeNotificationType, | |||
FileComparePreviousCommandType, | |||
FileCompareWorkingCommandType, | |||
FileMoreActionsCommandType, | |||
OpenFileCommandType, | |||
OpenFileOnRemoteCommandType, | |||
PickCommitCommandType, | |||
RichContentNotificationType, | |||
State, | |||
} from '../../commitDetails/protocol'; | |||
import { App } from '../shared/appBase'; | |||
import { FileChangeItem, FileChangeItemEventDetail } from '../shared/components/commit/file-change-item'; | |||
import { DOM } from '../shared/dom'; | |||
import './commitDetails.scss'; | |||
import '../shared/components/codicon'; | |||
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'; | |||
// eslint-disable-next-line @typescript-eslint/no-duplicate-imports | |||
import '../shared/components/commit/file-change-item'; | |||
import '../shared/components/webview-pane'; | |||
export class CommitDetailsApp extends App<State> { | |||
constructor() { | |||
super('CommitDetailsApp'); | |||
console.log('CommitDetailsApp', this.state); | |||
} | |||
override onInitialize() { | |||
console.log('CommitDetailsApp onInitialize', this.state); | |||
this.renderContent(); | |||
} | |||
override onBind() { | |||
const disposables = [ | |||
DOM.on<FileChangeItem, FileChangeItemEventDetail>('file-change-item', 'file-open-on-remote', e => | |||
this.onOpenFileOnRemote(e.detail), | |||
), | |||
DOM.on<FileChangeItem, FileChangeItemEventDetail>('file-change-item', 'file-open', e => | |||
this.onOpenFile(e.detail), | |||
), | |||
DOM.on<FileChangeItem, FileChangeItemEventDetail>('file-change-item', 'file-compare-working', e => | |||
this.onCompareFileWithWorking(e.detail), | |||
), | |||
DOM.on<FileChangeItem, FileChangeItemEventDetail>('file-change-item', 'file-compare-previous', e => | |||
this.onCompareFileWithPrevious(e.detail), | |||
), | |||
DOM.on<FileChangeItem, FileChangeItemEventDetail>('file-change-item', 'file-more-actions', e => | |||
this.onFileMoreActions(e.detail), | |||
), | |||
DOM.on('[data-action="commit-show-actions"]', 'click', e => this.onCommitMoreActions(e)), | |||
DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)), | |||
]; | |||
return disposables; | |||
} | |||
onPickCommit(e: MouseEvent) { | |||
this.sendCommand(PickCommitCommandType, undefined); | |||
} | |||
onOpenFileOnRemote(e: FileChangeItemEventDetail) { | |||
this.sendCommand(OpenFileOnRemoteCommandType, e); | |||
} | |||
onOpenFile(e: FileChangeItemEventDetail) { | |||
this.sendCommand(OpenFileCommandType, e); | |||
} | |||
onCompareFileWithWorking(e: FileChangeItemEventDetail) { | |||
this.sendCommand(FileCompareWorkingCommandType, e); | |||
} | |||
onCompareFileWithPrevious(e: FileChangeItemEventDetail) { | |||
this.sendCommand(FileComparePreviousCommandType, e); | |||
} | |||
onFileMoreActions(e: FileChangeItemEventDetail) { | |||
this.sendCommand(FileMoreActionsCommandType, e); | |||
} | |||
onCommitMoreActions(e: MouseEvent) { | |||
e.preventDefault(); | |||
if (this.state.selected === undefined) { | |||
e.stopPropagation(); | |||
return; | |||
} | |||
this.sendCommand(CommitActionsCommandType, undefined); | |||
} | |||
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; | |||
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()) { | |||
return; | |||
} | |||
this.renderMessage(); | |||
this.renderAutolinks(); | |||
} | |||
renderContent() { | |||
if (!this.renderCommit()) { | |||
return; | |||
} | |||
this.renderMessage(); | |||
this.renderAuthor(); | |||
this.renderStats(); | |||
this.renderFiles(); | |||
if (this.state.includeRichContent) { | |||
this.renderAutolinks(); | |||
} | |||
} | |||
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() { | |||
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 = ` | |||
<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats> | |||
`; | |||
} else { | |||
$el.innerHTML = ''; | |||
} | |||
} | |||
renderFiles() { | |||
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 | |||
.map( | |||
(file: Record<string, any>) => ` | |||
<li class="change-list__item"> | |||
<file-change-item class="commit-details__file" status="${file.status}" path="${file.path}" repo-path="${file.repoPath}" icon="${file.icon.dark}"></file-change-item> | |||
</li> | |||
`, | |||
) | |||
.join(''); | |||
$el.setAttribute('aria-hidden', 'false'); | |||
} else { | |||
$el.innerHTML = ''; | |||
} | |||
} | |||
renderAuthor() { | |||
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) { | |||
$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 | |||
></commit-identity> | |||
`; | |||
$el.setAttribute('aria-hidden', 'false'); | |||
} else { | |||
$el.innerHTML = ''; | |||
$el.setAttribute('aria-hidden', 'true'); | |||
} | |||
} | |||
renderTitle() { | |||
// <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) { | |||
$el.innerHTML = this.state.selected.shortSha; | |||
} | |||
} | |||
renderMessage() { | |||
const $el = document.querySelector<HTMLElement>('[data-region="message"]'); | |||
if ($el == null) { | |||
return; | |||
} | |||
const [headline, ...lines] = this.state.selected.message.split('\n'); | |||
if (lines.length > 1) { | |||
$el.innerHTML = `<strong>${headline}</strong><br>${lines.join('<br>')}`; | |||
} else { | |||
$el.innerHTML = `<strong>${headline}</strong>`; | |||
} | |||
} | |||
renderAutolinks() { | |||
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) { | |||
$el.setAttribute('aria-hidden', 'false'); | |||
$info?.setAttribute('aria-hidden', 'true'); | |||
this.renderPullRequest(); | |||
this.renderIssues(); | |||
} else { | |||
$el.setAttribute('aria-hidden', 'true'); | |||
$info?.setAttribute('aria-hidden', 'false'); | |||
} | |||
} | |||
renderPullRequest() { | |||
const $el = document.querySelector<HTMLElement>('[data-region="pull-request"]'); | |||
if ($el == null) { | |||
return; | |||
} | |||
if (this.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}" | |||
></issue-pull-request> | |||
`; | |||
$el.setAttribute('aria-hidden', 'false'); | |||
} else { | |||
$el.innerHTML = ''; | |||
$el.setAttribute('aria-hidden', 'true'); | |||
} | |||
} | |||
renderIssues() { | |||
const $el = document.querySelector<HTMLElement>('[data-region="issue"]'); | |||
if ($el == null) { | |||
return; | |||
} | |||
if (this.state.issues?.length > 0) { | |||
$el.innerHTML = this.state.issues | |||
.map( | |||
(issue: Record<string, any>) => ` | |||
<issue-pull-request | |||
name="${issue.title}" | |||
url="${issue.url}" | |||
key="${issue.id}" | |||
status="${(issue.closed as boolean) ? 'closed' : 'opened'}" | |||
date="${(issue.closed as boolean) ? issue.closedDate : issue.date}" | |||
></issue-pull-request> | |||
`, | |||
) | |||
.join(''); | |||
$el.setAttribute('aria-hidden', 'false'); | |||
} else { | |||
$el.innerHTML = ''; | |||
$el.setAttribute('aria-hidden', 'true'); | |||
} | |||
} | |||
} | |||
new CommitDetailsApp(); |
@ -0,0 +1,67 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import '../formatted-date'; | |||
@customElement('commit-identity') | |||
export class CommitIdentity extends LitElement { | |||
static override styles = css` | |||
:host { | |||
display: grid; | |||
gap: 0.25rem 0.5rem; | |||
justify-content: start; | |||
} | |||
a { | |||
color: var(--color-link-foreground); | |||
text-decoration: none; | |||
} | |||
.avatar { | |||
grid-column: 1; | |||
grid-row: 1 / 3; | |||
width: 36px; | |||
} | |||
.thumb { | |||
width: 100%; | |||
height: auto; | |||
border-radius: 0.4rem; | |||
} | |||
.name { | |||
grid-column: 2; | |||
grid-row: 1; | |||
font-size: 1.5rem; | |||
} | |||
.date { | |||
grid-column: 2; | |||
grid-row: 2; | |||
font-size: 1.2rem; | |||
} | |||
`; | |||
@property() | |||
name = ''; | |||
@property() | |||
email = ''; | |||
@property() | |||
date = ''; | |||
@property() | |||
avatar = 'https://www.gravatar.com/avatar/?s=16&d=robohash'; | |||
@property({ type: Boolean, reflect: true }) | |||
committer = false; | |||
override render() { | |||
const largerUrl = this.avatar.replace('s=32', 's=64'); | |||
return html` | |||
<a class="avatar" href="${this.email ? `mailto:${this.email}` : '#'}" | |||
><img class="thumb" lazy src="${largerUrl}" alt="${this.name}" | |||
/></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 | |||
></span> | |||
`; | |||
} | |||
} |
@ -0,0 +1,42 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import '../codicon'; | |||
@customElement('commit-stats') | |||
export class CommitStats extends LitElement { | |||
static override styles = css` | |||
:host { | |||
display: inline-flex; | |||
flex-direction: row; | |||
gap: 0.5rem; | |||
} | |||
.added { | |||
color: var(--vscode-gitDecoration-addedResourceForeground); | |||
} | |||
.modified { | |||
color: var(--vscode-gitDecoration-modifiedResourceForeground); | |||
} | |||
.deleted { | |||
color: var(--vscode-gitDecoration-deletedResourceForeground); | |||
} | |||
} | |||
`; | |||
@property({ type: Number }) | |||
added = 0; | |||
@property({ type: Number }) | |||
modified = 0; | |||
@property({ type: Number }) | |||
removed = 0; | |||
override render() { | |||
return html` | |||
<span class="added"><code-icon icon="diff-added"></code-icon> ${this.added}</span> | |||
<span class="modified"><code-icon icon="diff-modified"></code-icon> ${this.modified}</span> | |||
<span class="deleted"><code-icon icon="diff-removed"></code-icon> ${this.removed}</span> | |||
`; | |||
} | |||
} |
@ -0,0 +1,233 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import '../codicon'; | |||
export interface FileChangeItemEventDetail { | |||
path: string; | |||
repoPath: string; | |||
} | |||
// TODO: use the model version | |||
const statusTextMap: Record<string, string> = { | |||
'.': 'Unchanged', | |||
'!': 'Ignored', | |||
'?': 'Untracked', | |||
A: 'Added', | |||
D: 'Deleted', | |||
M: 'Modified', | |||
R: 'Renamed', | |||
C: 'Copied', | |||
AA: 'Conflict', | |||
AU: 'Conflict', | |||
UA: 'Conflict', | |||
DD: 'Conflict', | |||
DU: 'Conflict', | |||
UD: 'Conflict', | |||
UU: 'Conflict', | |||
T: 'Modified', | |||
U: 'Updated but Unmerged', | |||
}; | |||
// TODO: use the model version | |||
const statusCodiconsMap: Record<string, string | undefined> = { | |||
'.': undefined, | |||
'!': 'diff-ignored', | |||
'?': 'diff-added', | |||
A: 'diff-added', | |||
D: 'diff-removed', | |||
M: 'diff-modified', | |||
R: 'diff-renamed', | |||
C: 'diff-added', | |||
AA: 'warning', | |||
AU: 'warning', | |||
UA: 'warning', | |||
DD: 'warning', | |||
DU: 'warning', | |||
UD: 'warning', | |||
UU: 'warning', | |||
T: 'diff-modified', | |||
U: 'diff-modified', | |||
}; | |||
@customElement('file-change-item') | |||
export class FileChangeItem extends LitElement { | |||
static override styles = css` | |||
:host { | |||
display: flex; | |||
flex-direction: row; | |||
align-items: center; | |||
justify-content: space-between; | |||
font-size: var(--vscode-font-size); | |||
line-height: 2rem; | |||
color: var(--vscode-foreground); | |||
} | |||
:host(:hover), | |||
:host(:focus-within) { | |||
background-color: var(--vscode-list-hoverBackground); | |||
} | |||
:host(:focus-within) { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
* { | |||
box-sizing: border-box; | |||
} | |||
.change-list__link { | |||
width: 100%; | |||
color: inherit; | |||
white-space: nowrap; | |||
text-overflow: ellipsis; | |||
overflow: hidden; | |||
text-decoration: none; | |||
outline: none; | |||
} | |||
.change-list__status { | |||
margin-right: 0.33em; | |||
} | |||
.change-list__status-icon { | |||
width: 16px; | |||
aspect-ratio: 1; | |||
vertical-align: text-bottom; | |||
} | |||
.change-list__path { | |||
color: var(--color-background--lighten-50); | |||
} | |||
.change-list__actions { | |||
flex: none; | |||
user-select: none; | |||
display: flex; | |||
align-items: center; | |||
} | |||
:host(:not(:hover):not(:focus-within)) .change-list__actions { | |||
display: none; | |||
} | |||
.change-list__action { | |||
display: inline-flex; | |||
justify-content: center; | |||
align-items: center; | |||
width: 2rem; | |||
height: 2rem; | |||
border-radius: 0.25em; | |||
color: inherit; | |||
padding: 2px; | |||
vertical-align: text-bottom; | |||
text-decoration: none; | |||
} | |||
.change-list__action:hover { | |||
background-color: var(--color-background--lighten-15); | |||
} | |||
`; | |||
@property() | |||
status = ''; | |||
@property() | |||
path = ''; | |||
@property({ attribute: 'repo-path' }) | |||
repoPath = ''; | |||
@property() | |||
icon = ''; | |||
private renderIcon() { | |||
if (this.icon !== '') { | |||
return html`<img class="change-list__status-icon" src="${this.icon}" />`; | |||
} | |||
const statusIcon = (this.status !== '' && statusCodiconsMap[this.status]) ?? 'file'; | |||
return html` <code-icon icon="${statusIcon}"></code-icon> `; | |||
} | |||
override render() { | |||
const statusName = this.status !== '' ? statusTextMap[this.status] : ''; | |||
const pathIndex = this.path.lastIndexOf('/'); | |||
const fileName = pathIndex > -1 ? this.path.substring(pathIndex + 1) : this.path; | |||
const filePath = pathIndex > -1 ? this.path.substring(0, pathIndex) : ''; | |||
return html` | |||
<a class="change-list__link" @click=${this.onComparePrevious} href="#"> | |||
<span class="change-list__status" aria-label="${statusName}">${this.renderIcon()}</span | |||
><span class="change-list__filename">${fileName}</span> | |||
<small class="change-list__path">${filePath}</small> | |||
</a> | |||
<nav class="change-list__actions"> | |||
<a | |||
class="change-list__action" | |||
@click=${this.onOpenFile} | |||
href="#" | |||
title="Open file" | |||
aria-label="Open file" | |||
><code-icon icon="go-to-file"></code-icon></a | |||
><a | |||
class="change-list__action" | |||
@click=${this.onCompareWorking} | |||
href="#" | |||
title="Compare" | |||
aria-label="Compare" | |||
><code-icon icon="git-compare"></code-icon></a | |||
><a | |||
class="change-list__action" | |||
@click=${this.onOpenFileOnRemote} | |||
href="#" | |||
title="Open on remote" | |||
aria-label="Open on remote" | |||
><code-icon icon="globe"></code-icon></a | |||
><a | |||
class="change-list__action" | |||
@click=${this.onMoreActions} | |||
href="#" | |||
title="Show more actions" | |||
aria-label="Show more actions" | |||
><code-icon icon="ellipsis"></code-icon | |||
></a> | |||
</nav> | |||
`; | |||
} | |||
private onOpenFile(e: Event) { | |||
e.preventDefault(); | |||
this.fireEvent('file-open'); | |||
} | |||
private onOpenFileOnRemote(e: Event) { | |||
e.preventDefault(); | |||
this.fireEvent('file-open-on-remote'); | |||
} | |||
private onCompareWorking(e: Event) { | |||
e.preventDefault(); | |||
this.fireEvent('file-compare-working'); | |||
} | |||
private onComparePrevious(e: Event) { | |||
e.preventDefault(); | |||
this.fireEvent('file-compare-previous'); | |||
} | |||
private onMoreActions(e: Event) { | |||
e.preventDefault(); | |||
this.fireEvent('file-more-actions'); | |||
} | |||
private fireEvent(eventName: string) { | |||
const event = new CustomEvent<FileChangeItemEventDetail>(eventName, { | |||
detail: { | |||
path: this.path, | |||
repoPath: this.repoPath, | |||
}, | |||
bubbles: true, | |||
composed: true, | |||
}); | |||
this.dispatchEvent(event); | |||
} | |||
} |
@ -0,0 +1,12 @@ | |||
import type { ComplexAttributeConverter } from 'lit'; | |||
export const dateConverter = (locale?: string): ComplexAttributeConverter<Date> => { | |||
return { | |||
toAttribute: (date: Date) => { | |||
return date.toLocaleDateString(locale); | |||
}, | |||
fromAttribute: (value: string) => { | |||
return new Date(value); | |||
}, | |||
}; | |||
}; |
@ -0,0 +1,19 @@ | |||
import { html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import { formatDate, fromNow } from '../../../../system/date'; | |||
import { dateConverter } from './converters/date-converter'; | |||
@customElement('formatted-date') | |||
export class FormattedDate extends LitElement { | |||
@property() | |||
format = 'MMMM Do, YYYY h:mma'; | |||
@property({ converter: dateConverter(navigator.language), reflect: true }) | |||
date = new Date(); | |||
override render() { | |||
return html`<time datetime="${this.date}" title="${formatDate(this.date, this.format ?? 'MMMM Do, YYYY h:mma')}" | |||
>${fromNow(this.date)}</time | |||
>`; | |||
} | |||
} |
@ -0,0 +1,77 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import '../formatted-date'; | |||
import '../codicon'; | |||
@customElement('issue-pull-request') | |||
export class IssuePullRequest extends LitElement { | |||
static override styles = css` | |||
:host { | |||
display: grid; | |||
gap: 0.25rem 0.5rem; | |||
justify-content: start; | |||
} | |||
a { | |||
color: var(--color-link-foreground); | |||
text-decoration: none; | |||
} | |||
.icon { | |||
grid-column: 1; | |||
grid-row: 1 / 3; | |||
color: var(--vscode-gitlens-mergedPullRequestIconColor); | |||
width: 32px; | |||
text-align: center; | |||
} | |||
.title { | |||
grid-column: 2; | |||
grid-row: 1; | |||
margin: 0; | |||
font-size: 1.5rem; | |||
} | |||
.date { | |||
grid-column: 2; | |||
grid-row: 2; | |||
margin: 0; | |||
font-size: 1.2rem; | |||
} | |||
`; | |||
@property() | |||
url = ''; | |||
@property() | |||
name = ''; | |||
@property() | |||
date = ''; | |||
@property() | |||
status = 'merged'; | |||
@property() | |||
key = '#1999'; | |||
override render() { | |||
const icon = | |||
this.status.toLowerCase() === 'merged' | |||
? 'git-merge' | |||
: this.status.toLowerCase() === 'closed' | |||
? 'pass' | |||
: 'issues'; | |||
return html` | |||
<span class="icon"><code-icon icon=${icon}></code-icon></span> | |||
<p class="title"> | |||
<a href="${this.url}">${this.name}</a> | |||
</p> | |||
<p class="date"> | |||
${this.key} ${this.status} | |||
<formatted-date date="${this.date}"></formatted-date> | |||
</p> | |||
`; | |||
} | |||
} |
@ -0,0 +1,57 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
// height: calc(1em * var(--skeleton-line-height, 1.2) * var(--skeleton-lines, 1)); | |||
// background-color: var(--color-background--lighten-30); | |||
@customElement('skeleton-loader') | |||
export class SkeletonLoader extends LitElement { | |||
static override styles = css` | |||
:host { | |||
--skeleton-line-height: 1.2; | |||
--skeleton-lines: 1; | |||
} | |||
.skeleton { | |||
position: relative; | |||
display: block; | |||
overflow: hidden; | |||
border-radius: 0.25em; | |||
width: 100%; | |||
height: calc(1em * var(--skeleton-line-height, 1.2) * var(--skeleton-lines, 1)); | |||
background-color: var(--color-background--lighten-15); | |||
} | |||
.skeleton::before { | |||
content: ''; | |||
position: absolute; | |||
display: block; | |||
top: 0; | |||
right: 0; | |||
bottom: 0; | |||
left: 0; | |||
background-image: linear-gradient( | |||
to right, | |||
transparent 0%, | |||
var(--color-background--lighten-15) 20%, | |||
var(--color-background--lighten-30) 60%, | |||
transparent 100% | |||
); | |||
transform: translateX(-100%); | |||
animation: skeleton-loader 2s ease-in-out infinite; | |||
} | |||
@keyframes skeleton-loader { | |||
100% { | |||
transform: translateX(100%); | |||
} | |||
} | |||
`; | |||
@property({ type: Number }) | |||
lines = 1; | |||
override render() { | |||
const style = `--skeleton-lines: ${this.lines};`; | |||
return html`<div class="skeleton" style=${style}></div>`; | |||
} | |||
} |
@ -0,0 +1,97 @@ | |||
import { css, html, LitElement } from 'lit'; | |||
import { customElement, property } from 'lit/decorators.js'; | |||
import './codicon'; | |||
@customElement('webview-pane') | |||
export class WebviewPane extends LitElement { | |||
static override styles = css` | |||
:host { | |||
display: flex; | |||
flex-direction: column; | |||
background-color: var(--color-view-background); | |||
color: var(--color-view-foreground); | |||
} | |||
* { | |||
box-sizing: border-box; | |||
} | |||
.header { | |||
flex: none; | |||
display: flex; | |||
background-color: var(--color-view-background); | |||
color: var(--color-view-header-foreground); | |||
border-top: 1px solid var(--vscode-panel-border); | |||
} | |||
.header:focus-within { | |||
outline: 1px solid var(--vscode-focusBorder); | |||
outline-offset: -1px; | |||
} | |||
.title { | |||
appearance: none; | |||
width: 100%; | |||
padding: 0; | |||
border: none; | |||
text-align: left; | |||
line-height: 2.2rem; | |||
font-weight: bold; | |||
background: transparent; | |||
color: inherit; | |||
cursor: pointer; | |||
outline: none; | |||
} | |||
.icon { | |||
font-weight: normal; | |||
margin: 0 0.2rem; | |||
} | |||
.content { | |||
overflow: auto; | |||
/* scrollbar-gutter: stable; */ | |||
box-shadow: #000000 0 0.6rem 0.6rem -0.6rem inset; | |||
padding-top: 0.6rem; | |||
} | |||
:host([collapsable]:not([expanded])) .content { | |||
display: none; | |||
} | |||
`; | |||
@property({ type: Boolean, reflect: true }) | |||
collapsable = false; | |||
@property({ type: Boolean, reflect: true }) | |||
expanded = false; | |||
renderTitle() { | |||
if (!this.collapsable) { | |||
return html`<div class="title">${this.title}</div>`; | |||
} | |||
return html`<button | |||
type="button" | |||
class="title" | |||
aria-controls="content" | |||
aria-expanded=${this.expanded} | |||
@click="${this.toggleExpanded}" | |||
> | |||
<code-icon class="icon" icon=${this.expanded ? 'chevron-down' : 'chevron-right'}></code-icon | |||
><slot name="title">Section</slot> | |||
</button>`; | |||
} | |||
override render() { | |||
return html` | |||
<header class="header">${this.renderTitle()}</header> | |||
<div id="content" role="region" class="content"> | |||
<slot></slot> | |||
</div> | |||
`; | |||
} | |||
private toggleExpanded() { | |||
this.expanded = !this.expanded; | |||
} | |||
} |
@ -0,0 +1,198 @@ | |||
import { ProgressLocation, window } from 'vscode'; | |||
import { Commands } from '../../constants'; | |||
import type { Container } from '../../container'; | |||
import { GitCommit, GitRemote, IssueOrPullRequest } from '../../git/models'; | |||
import { RichRemoteProvider } from '../../git/remotes/provider'; | |||
import { debug } from '../../system/decorators/log'; | |||
import { WebviewBase } from '../webviewBase'; | |||
import type { CommitDetails, CommitSummary, ShowCommitDetailsPageCommandArgs, State } from './protocol'; | |||
export class CommitDetailsWebview extends WebviewBase<State> { | |||
private shaList: string[] = [ | |||
'7224b547bbaa3a643e89ceb515dfb7cbad83aa26', | |||
'f55b2ad418a05a51c381c667e5e87d0435883cfc', | |||
]; | |||
private selectedSha: string | undefined = 'f55b2ad418a05a51c381c667e5e87d0435883cfc'; | |||
constructor(container: Container) { | |||
super( | |||
container, | |||
'gitlens.commitDetails', | |||
'commitDetails.html', | |||
'images/gitlens-icon.png', | |||
'Commit Details', | |||
Commands.ShowCommitDetailsPage, | |||
); | |||
} | |||
private updateShaList(refs?: string[]) { | |||
let refsList; | |||
if (refs?.length && refs.length > 0) { | |||
refsList = refs; | |||
} else { | |||
// TODO: replace with quick pick for a commit | |||
refsList = ['7224b547bbaa3a643e89ceb515dfb7cbad83aa26', 'f55b2ad418a05a51c381c667e5e87d0435883cfc']; | |||
} | |||
this.shaList = refsList; | |||
if (this.selectedSha && !this.shaList.includes(this.selectedSha)) { | |||
// TODO: maybe make a quick pick for the list of commits? | |||
this.selectedSha = this.shaList[0]; | |||
} | |||
} | |||
protected override onShowCommand(refs?: ShowCommitDetailsPageCommandArgs): void { | |||
// TODO: get args from command | |||
this.updateShaList(refs); | |||
super.onShowCommand(); | |||
} | |||
private async getLinkedIssuesAndPullRequests( | |||
message: string, | |||
remote: GitRemote<RichRemoteProvider>, | |||
): Promise<IssueOrPullRequest[] | undefined> { | |||
try { | |||
const issueSearch = await this.container.autolinks.getLinkedIssuesAndPullRequests(message, remote); | |||
console.log('CommitDetailsWebview getLinkedIssuesAndPullRequests', issueSearch); | |||
if (issueSearch != null) { | |||
const filteredIssues = Array.from(issueSearch.values()).filter( | |||
value => value != null, | |||
) as IssueOrPullRequest[]; | |||
return filteredIssues; | |||
} | |||
return undefined; | |||
} catch (e) { | |||
console.error(e); | |||
return undefined; | |||
} | |||
} | |||
private async getRichContent(selected: GitCommit): Promise<Record<string, any>> { | |||
const pullRequest = selected != null ? await selected.getAssociatedPullRequest() : undefined; | |||
console.log('CommitDetailsWebview pullRequest', pullRequest); | |||
const issues: Record<string, any>[] = []; | |||
let formattedMessage; | |||
if (selected?.message !== undefined && typeof selected.message === 'string') { | |||
const remote = await this.container.git.getBestRemoteWithRichProvider(selected.repoPath); | |||
console.log('CommitDetailsWebview remote', remote); | |||
if (remote != null) { | |||
formattedMessage = this.container.autolinks.linkify(selected.message, true, [remote]); | |||
const issueSearch = await this.getLinkedIssuesAndPullRequests(selected.message, remote); | |||
console.log('CommitDetailsWebview issueSearch', issueSearch); | |||
if (issueSearch !== undefined) { | |||
issues.push(...issueSearch); | |||
} | |||
} | |||
} | |||
return { | |||
formattedMessage: formattedMessage, | |||
pullRequest: pullRequest, | |||
issues: issues?.length ? issues : undefined, | |||
}; | |||
} | |||
@debug({ args: false }) | |||
protected async getState(init = false): Promise<State> { | |||
const repo = this.container.git.openRepositories?.[0]; | |||
console.log('CommitDetailsWebview repo', repo); | |||
if (repo === undefined) { | |||
return { | |||
commits: [], | |||
}; | |||
} | |||
const commitPromises = this.shaList.map(sha => repo.getCommit(sha)); | |||
const results = await Promise.all(commitPromises); | |||
console.log('CommitDetailsWebview results', results); | |||
const commits = results.filter(commit => commit !== undefined) as GitCommit[]; | |||
const selected = commits.find(commit => commit.sha === this.selectedSha); | |||
console.log('CommitDetailsWebview selected', selected); | |||
// const pullRequest = selected != null ? await selected.getAssociatedPullRequest() : undefined; | |||
// console.log('CommitDetailsWebview pullRequest', pullRequest); | |||
// const issues: Record<string, any>[] = []; | |||
// let formattedMessage; | |||
// if (selected?.message !== undefined && typeof selected.message === 'string') { | |||
// const remote = await this.container.git.getBestRemoteWithRichProvider(selected.repoPath); | |||
// console.log('CommitDetailsWebview remote', remote); | |||
// if (remote != null) { | |||
// formattedMessage = this.container.autolinks.linkify(selected.message, true, [remote]); | |||
// const issueSearch = await this.getLinkedIssuesAndPullRequests(selected.message, remote); | |||
// console.log('CommitDetailsWebview issueSearch', issueSearch); | |||
// if (issueSearch !== undefined) { | |||
// issues.push(...issueSearch); | |||
// } | |||
// } | |||
// } | |||
const richContent = !init && selected != null ? await this.getRichContent(selected) : undefined; | |||
let formattedCommit; | |||
if (selected !== undefined) { | |||
formattedCommit = await getDetailsModel(selected, richContent?.formattedMessage); | |||
} | |||
const commitChoices = await Promise.all(commits.map(async commit => summaryModel(commit))); | |||
return { | |||
// TODO: keep state of the selected commit | |||
commits: commitChoices, | |||
selected: formattedCommit, | |||
pullRequest: richContent?.pullRequest, | |||
issues: richContent?.issues, | |||
}; | |||
} | |||
protected override async includeBootstrap() { | |||
return window.withProgress({ location: ProgressLocation.Window, title: 'Loading webview...' }, () => | |||
this.getState(true), | |||
); | |||
} | |||
} | |||
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), | |||
}; | |||
} | |||
async function getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise<CommitDetails | undefined> { | |||
if (commit === undefined) { | |||
return; | |||
} | |||
const authorAvatar = await commit.author?.getAvatarUri(commit); | |||
const committerAvatar = await commit.committer?.getAvatarUri(commit); | |||
return { | |||
sha: commit.sha, | |||
shortSha: commit.shortSha, | |||
summary: commit.summary, | |||
message: formattedMessage ?? commit.message, | |||
author: { ...commit.author, avatar: authorAvatar?.toString(true) }, | |||
committer: { ...commit.committer, avatar: committerAvatar?.toString(true) }, | |||
files: commit.files?.map(({ repoPath, path, status }) => ({ repoPath: repoPath, path: path, status: status })), | |||
stats: commit.stats, | |||
}; | |||
} |
@ -0,0 +1,347 @@ | |||
import { Uri, window } from 'vscode'; | |||
import type { | |||
DiffWithPreviousCommandArgs, | |||
DiffWithWorkingCommandArgs, | |||
OpenFileOnRemoteCommandArgs, | |||
} from '../../commands'; | |||
import { Commands, CoreCommands } from '../../constants'; | |||
import type { Container } from '../../container'; | |||
import { GitUri } from '../../git/gitUri'; | |||
import { GitCommit, GitFile, IssueOrPullRequest } from '../../git/models'; | |||
import { executeCommand, executeCoreCommand } from '../../system/command'; | |||
import { debug } from '../../system/decorators/log'; | |||
import { IpcMessage, onIpc } from '../protocol'; | |||
import { WebviewViewBase } from '../webviewViewBase'; | |||
import { | |||
CommitActionsCommandType, | |||
CommitDetails, | |||
CommitSummary, | |||
FileComparePreviousCommandType, | |||
FileCompareWorkingCommandType, | |||
FileMoreActionsCommandType, | |||
FileParams, | |||
OpenFileCommandType, | |||
OpenFileOnRemoteCommandType, | |||
PickCommitCommandType, | |||
RichCommitDetails, | |||
RichContentNotificationType, | |||
State, | |||
} from './protocol'; | |||
export class CommitDetailsWebviewView extends WebviewViewBase<State> { | |||
private originalTitle?: string; | |||
private commits?: GitCommit[]; | |||
private selectedCommit?: GitCommit; | |||
private loadedOnce = false; | |||
constructor(container: Container) { | |||
super(container, 'gitlens.views.commitDetails', 'commitDetails.html', 'Commit Details'); | |||
this.originalTitle = this.title; | |||
} | |||
override async show(options?: { commit?: GitCommit; preserveFocus?: boolean | undefined }): Promise<void> { | |||
if (options?.commit != null) { | |||
this.selectCommit(options.commit); | |||
void this.refresh(); | |||
} | |||
return super.show(options != null ? { preserveFocus: options.preserveFocus } : undefined); | |||
} | |||
protected override onMessageReceived(e: IpcMessage) { | |||
switch (e.method) { | |||
case OpenFileOnRemoteCommandType.method: | |||
onIpc(OpenFileOnRemoteCommandType, e, params => { | |||
this.openFileOnRemote(params); | |||
}); | |||
break; | |||
case OpenFileCommandType.method: | |||
onIpc(OpenFileCommandType, e, params => { | |||
this.openFile(params); | |||
}); | |||
break; | |||
case FileCompareWorkingCommandType.method: | |||
onIpc(FileCompareWorkingCommandType, e, params => { | |||
this.openFileComparisonWithWorking(params); | |||
}); | |||
break; | |||
case FileComparePreviousCommandType.method: | |||
onIpc(FileComparePreviousCommandType, e, params => { | |||
this.openFileComparisonWithPrevious(params); | |||
}); | |||
break; | |||
case FileMoreActionsCommandType.method: | |||
onIpc(FileMoreActionsCommandType, e, params => { | |||
this.showFileActions(params); | |||
}); | |||
break; | |||
case CommitActionsCommandType.method: | |||
onIpc(CommitActionsCommandType, e, params => { | |||
this.showCommitActions(); | |||
}); | |||
break; | |||
case PickCommitCommandType.method: | |||
onIpc(PickCommitCommandType, e, params => { | |||
this.showCommitSearch(); | |||
}); | |||
break; | |||
} | |||
} | |||
private getFileFromParams(params: FileParams): GitFile | undefined { | |||
return this.selectedCommit?.files?.find(file => file.path === params.path && file.repoPath === params.repoPath); | |||
} | |||
private showCommitSearch() { | |||
void executeCommand(Commands.SearchCommits, { | |||
showResultsInDetails: true, | |||
}); | |||
} | |||
private showCommitActions() { | |||
if (this.selectedCommit === undefined) { | |||
return; | |||
} | |||
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 openFileComparisonWithWorking(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<[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 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, | |||
}, | |||
}); | |||
} | |||
private openFile(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 executeCoreCommand(CoreCommands.Open, uri, { background: false, preview: false }); | |||
} | |||
private openFileOnRemote(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<[Uri, OpenFileOnRemoteCommandArgs]>(Commands.OpenFileOnRemote, uri, { | |||
sha: this.selectedCommit?.sha, | |||
}); | |||
} | |||
private copyRemoteFileUrl() {} | |||
private async getRichContent(selected: GitCommit): Promise<RichCommitDetails> { | |||
const pullRequest = selected != null ? await selected.getAssociatedPullRequest() : undefined; | |||
console.log('CommitDetailsWebview pullRequest', pullRequest); | |||
const issues: Record<string, any>[] = []; | |||
let formattedMessage; | |||
if (selected?.message !== undefined && typeof selected.message === 'string') { | |||
const remote = await this.container.git.getBestRemoteWithRichProvider(selected.repoPath); | |||
console.log('CommitDetailsWebview remote', remote); | |||
if (remote != null) { | |||
const issueSearch = await this.container.autolinks.getLinkedIssuesAndPullRequests( | |||
selected.message, | |||
remote, | |||
); | |||
// TODO: add HTML formatting option to linkify | |||
// formattedMessage = this.container.autolinks.linkify( | |||
// escapeMarkdown(selected.message, { quoted: true }), | |||
// true, | |||
// [remote], | |||
// // issueSearch, | |||
// ); | |||
formattedMessage = this.container.autolinks.linkify( | |||
encodeMarkup(selected.message), | |||
true, | |||
[remote], | |||
// issueSearch, | |||
); | |||
let filteredIssues; | |||
if (issueSearch != null) { | |||
if (pullRequest !== undefined) { | |||
issueSearch.delete(pullRequest.id); | |||
} | |||
filteredIssues = Array.from(issueSearch.values()).filter( | |||
value => value != null, | |||
) as IssueOrPullRequest[]; | |||
} | |||
console.log('CommitDetailsWebview filteredIssues', filteredIssues); | |||
if (filteredIssues !== undefined) { | |||
issues.push(...filteredIssues); | |||
} | |||
} | |||
} | |||
return { | |||
formattedMessage: formattedMessage, | |||
pullRequest: pullRequest, | |||
issues: issues?.length ? issues : undefined, | |||
}; | |||
} | |||
private selectCommit(commit: GitCommit) { | |||
this.commits = [commit]; | |||
this.selectedCommit = commit; | |||
this.title = `${this.originalTitle}${ | |||
this.selectedCommit !== undefined ? `: ${this.selectedCommit.shortSha}` : '' | |||
}`; | |||
} | |||
@debug({ args: false }) | |||
protected async getState(includeRichContent = true): Promise<State | undefined> { | |||
if (this.commits === undefined) { | |||
return; | |||
} | |||
console.log('CommitDetailsWebview selected', this.selectedCommit); | |||
let richContent; | |||
let formattedCommit; | |||
if (this.selectedCommit !== undefined) { | |||
if (includeRichContent) { | |||
richContent = await this.getRichContent(this.selectedCommit); | |||
} | |||
formattedCommit = await this.getDetailsModel(this.selectedCommit, richContent?.formattedMessage); | |||
} | |||
const commitChoices = await Promise.all(this.commits.map(async commit => summaryModel(commit))); | |||
return { | |||
includeRichContent: includeRichContent, | |||
commits: commitChoices, | |||
selected: formattedCommit, | |||
pullRequest: richContent?.pullRequest, | |||
issues: richContent?.issues, | |||
}; | |||
} | |||
protected override async includeBootstrap() { | |||
return window.withProgress({ location: { viewId: this.id } }, async () => { | |||
const state = await this.getState(this.loadedOnce); | |||
if (state === undefined) { | |||
return {}; | |||
} | |||
if (this.loadedOnce === false) { | |||
void this.updateRichContent(); | |||
this.loadedOnce = true; | |||
} | |||
return state; | |||
}); | |||
} | |||
private async updateRichContent() { | |||
if (this.selectedCommit === undefined) { | |||
return; | |||
} | |||
const richContent = await this.getRichContent(this.selectedCommit); | |||
if (richContent != null) { | |||
void this.notify(RichContentNotificationType, richContent); | |||
} | |||
} | |||
private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise<CommitDetails | undefined> { | |||
if (commit === undefined) { | |||
return; | |||
} | |||
const authorAvatar = await commit.author?.getAvatarUri(commit); | |||
// const committerAvatar = await commit.committer?.getAvatarUri(commit); | |||
return { | |||
sha: commit.sha, | |||
shortSha: commit.shortSha, | |||
summary: commit.summary, | |||
message: formattedMessage ?? encodeMarkup(commit.message ?? ''), | |||
author: { ...commit.author, avatar: authorAvatar?.toString(true) }, | |||
// committer: { ...commit.committer, avatar: committerAvatar?.toString(true) }, | |||
files: commit.files?.map(({ repoPath, path, status }) => { | |||
const icon = GitFile.getStatusIcon(status); | |||
return { | |||
repoPath: repoPath, | |||
path: path, | |||
status: status, | |||
icon: { | |||
dark: this._view!.webview.asWebviewUri( | |||
Uri.joinPath(this.container.context.extensionUri, 'images', 'dark', icon), | |||
).toString(), | |||
light: this._view!.webview.asWebviewUri( | |||
Uri.joinPath(this.container.context.extensionUri, 'images', 'light', icon), | |||
).toString(), | |||
}, | |||
}; | |||
}), | |||
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), | |||
}; | |||
} | |||
function encodeMarkup(text: string): string { | |||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |||
} |
@ -0,0 +1,49 @@ | |||
import { IpcCommandType, IpcNotificationType } from '../protocol'; | |||
export type CommitSummary = { | |||
sha: string; | |||
summary: string; | |||
message?: string; | |||
author: Record<string, any>; | |||
shortSha: string; | |||
avatar?: string; | |||
}; | |||
export type CommitDetails = { | |||
committer?: Record<string, any>; | |||
files?: Record<string, any>[]; | |||
stats?: Record<string, any>; | |||
} & CommitSummary; | |||
export type RichCommitDetails = { | |||
formattedMessage?: string; | |||
pullRequest?: Record<string, any>; | |||
issues?: Record<string, any>[]; | |||
}; | |||
export type State = { | |||
commits?: CommitSummary[]; | |||
} & Record<string, any>; | |||
export type ShowCommitDetailsPageCommandArgs = string[]; | |||
// COMMANDS | |||
export interface FileParams { | |||
path: string; | |||
repoPath: string; | |||
} | |||
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<undefined>('commit/moreActions'); | |||
export const PickCommitCommandType = new IpcCommandType<undefined>('commit/pickCommit'); | |||
// NOTIFICATIONS | |||
export interface DidChangeParams { | |||
state: State; | |||
} | |||
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('commit/didChange'); | |||
export const RichContentNotificationType = new IpcNotificationType<RichCommitDetails>('commit/richContent'); |