- 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": { | "env": { | ||||
"browser": true | "browser": true | ||||
}, | }, | ||||
"rules": { | |||||
"import/extensions": ["error", "never", { "js": "always" }] | |||||
}, | |||||
"parserOptions": { | "parserOptions": { | ||||
"project": "src/webviews/apps/tsconfig.json" | "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'); |