Browse Source

Updates commit details app to web component

main
Keith Daulton 1 year ago
parent
commit
6822b902f4
7 changed files with 947 additions and 1236 deletions
  1. +1
    -193
      src/webviews/apps/commitDetails/commitDetails.html
  2. +82
    -309
      src/webviews/apps/commitDetails/commitDetails.scss
  3. +20
    -567
      src/webviews/apps/commitDetails/commitDetails.ts
  4. +659
    -0
      src/webviews/apps/commitDetails/components/commit-details-app.ts
  5. +53
    -43
      src/webviews/apps/shared/components/actions/action-item.ts
  6. +62
    -57
      src/webviews/apps/shared/components/commit/commit-identity.ts
  7. +70
    -67
      src/webviews/apps/shared/components/progress.ts

+ 1
- 193
src/webviews/apps/commitDetails/commitDetails.html View File

@ -17,199 +17,7 @@
</head>
<body class="preload" data-placement="#{placement}">
<div class="commit-detail-panel scrollable">
<div class="commit-detail-panel__none" id="empty" aria-hidden="true">
<p>Rich details for commits and stashes are shown as you navigate:</p>
<ul class="bulleted">
<li>lines in the text editor</li>
<li>
commits in the <a href="command:gitlens.showGraph">Commit Graph</a>,
<a href="command:gitlens.showTimelineView">Visual File History</a>, or
<a href="command:gitlens.showCommitsView">Commits view</a>
</li>
<li>stashes in the <a href="command:gitlens.showStashesView">Stashes view</a></li>
</ul>
<p>Alternatively, search for or choose a commit</p>
<p class="button-container">
<span class="button-group">
<button class="button button--full" type="button" data-action="pick-commit">
Choose Commit...
</button>
<button
class="button"
type="button"
data-action="search-commit"
aria-label="Search for Commit"
title="Search for Commit"
>
<code-icon icon="search"></code-icon>
</button>
</span>
</p>
<!-- banner
<h2>Best practice</h2>
<p>To best take advantage of this view, we highly recommend moving it to the Secondary Side Bar.</p> -->
</div>
<main class="commit-details commit-detail-panel__main" id="main" tabindex="-1">
<div class="commit-details__top">
<div class="commit-details__top-menu">
<div class="commit-details__actionbar">
<div class="commit-details__actionbar-group">
<a
class="commit-action"
href="#"
data-action="pin"
aria-label="Pin this Commit"
title="Pin this Commit"
><code-icon icon="pin" data-region="commit-pin"></code-icon
></a>
<a class="commit-action" href="#" data-action="back" aria-label="Back" title="Back"
><code-icon icon="arrow-left" data-region="commit-back"></code-icon
></a>
<a
class="commit-action"
href="#"
data-action="forward"
aria-label="Forward"
title="Forward"
><code-icon icon="arrow-right" data-region="commit-forward"></code-icon
></a>
<a class="commit-action commit-action--emphasis-low" href="#" title="View this Commit"
><code-icon icon="git-commit"></code-icon><span data-region="commit-hint"></span
></a>
</div>
<div class="commit-details__actionbar-group">
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="sha"
aria-label="Copy SHA
[⌥] Pick Commit..."
title="Copy SHA
[⌥] Pick Commit..."
>
<code-icon icon="git-commit"></code-icon>
<span class="commit-details__sha" data-region="shortsha"></span
></a>
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="scm"
aria-label="Open SCM view"
title="Open SCM view"
><code-icon icon="source-control"></code-icon
></a>
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="graph"
aria-label="Open in Commit Graph"
title="Open in Commit Graph"
><code-icon icon="gl-graph"></code-icon
></a>
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="more"
aria-label="Show Commit Actions"
title="Show Commit Actions"
><code-icon icon="kebab-vertical"></code-icon
></a>
</div>
</div>
<ul class="commit-details__authors" aria-label="Authors">
<li class="commit-details__author" data-region="author">
<skeleton-loader></skeleton-loader>
</li>
</ul>
</div>
</div>
<div class="commit-details__message">
<p class="commit-details__message-text scrollable" data-region="message">
<skeleton-loader></skeleton-loader>
</p>
</div>
<webview-pane collapsable expanded data-region="rich-pane">
<span slot="title">Autolinks</span>
<span slot="subtitle" data-region="autolink-count"></span>
<div class="commit-details__rich" data-region="rich-info" aria-hidden="true">
<p>
<code-icon icon="info"></code-icon>&nbsp;Use
<a href="#" data-action="autolink-settings" title="Configure autolinks">autolinks</a>
to linkify external references, like Jira issues or Zendesk tickets, in commit messages.
</p>
</div>
<div class="commit-details__rich" data-region="autolinks">
<section
class="commit-details__autolinks"
aria-label="Custom Autolinks"
data-region="custom-autolinks"
>
<skeleton-loader lines="2"></skeleton-loader>
</section>
<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>
<span slot="subtitle" data-region="stats"></span>
<action-nav slot="actions">
<action-item data-switch-value="tree" label="View as Tree" icon="list-tree"></action-item>
</action-nav>
<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>
<webview-pane collapsable data-region="explain-pane">
<span slot="title">Explain (AI)</span>
<span slot="subtitle"><code-icon icon="beaker" size="12"></code-icon></span>
<action-nav slot="actions">
<action-item data-action="switch-ai" label="Switch AI Model" icon="hubot"></action-item>
</action-nav>
<div class="pane-content">
<p>Let AI assist in understanding the changes made with this commit.</p>
<p class="button-container">
<span class="button-group">
<button class="button button--full" type="button" data-action="explain-commit">
<code-icon icon="loading" modifier="spin"></code-icon>Explain this Commit
</button>
</span>
</p>
<div class="ai-content" data-region="commit-explanation"></div>
</div>
</webview-pane>
</main>
</div>
<gl-commit-details-app id="app"></gl-commit-details-app>
#{endOfBody}
</body>
</html>

+ 82
- 309
src/webviews/apps/commitDetails/commitDetails.scss View File

@ -124,119 +124,7 @@ ul {
}
}
.button-container {
margin: 1rem auto 0;
text-align: left;
max-width: 30rem;
transition: max-width 0.2s ease-out;
}
.button-group {
display: inline-flex;
gap: 0.1rem;
width: 100%;
max-width: 30rem;
}
.svg-themed {
--svg-outline: var(--color-foreground--50);
--svg-foreground: var(--color-link-foreground--lighten-20);
--svg-overlay: var(--color-highlight--25);
&__outline {
stroke: var(--svg-outline);
}
&__view {
fill: var(--svg-overlay);
stroke: var(--svg-foreground);
}
&__line {
stroke: var(--svg-foreground);
fill: var(--svg-foreground);
}
}
.switch {
margin-left: auto;
display: inline-flex;
flex-direction: row;
border-radius: 0.25em;
gap: 0.1rem;
.vscode-high-contrast &,
.vscode-dark & {
background-color: var(--color-background--lighten-075);
}
.vscode-high-contrast-light &,
.vscode-light & {
background-color: var(--color-background--darken-075);
}
&__option {
display: inline-flex;
justify-content: center;
align-items: flex-end;
border-radius: 0.25em;
color: inherit;
padding: 0.2rem 0.8rem;
text-decoration: none;
background: none;
border: none;
cursor: pointer;
> * {
pointer-events: none;
}
&:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
&:hover {
color: var(--vscode-foreground);
text-decoration: none;
.vscode-high-contrast &,
.vscode-dark & {
background-color: var(--color-background--lighten-10);
}
.vscode-high-contrast-light &,
.vscode-light & {
background-color: var(--color-background--darken-10);
}
}
&.is-selected {
color: var(--vscode-foreground);
.vscode-high-contrast &,
.vscode-dark & {
background-color: var(--color-background--lighten-15);
}
.vscode-high-contrast-light &,
.vscode-light & {
background-color: var(--color-background--darken-15);
}
}
}
}
@media (min-width: 640px) {
.button-container {
max-width: 100%;
}
}
.pane-content {
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width);
> :first-child {
margin-top: 0;
}
}
[data-action='explain-commit'] {
.button--busy {
code-icon {
margin-right: 0.5rem;
}
@ -252,123 +140,54 @@ ul {
}
}
// webview-specific styles
.ai-content {
.button-container {
margin: 1rem auto 0;
text-align: left;
max-width: 30rem;
transition: max-width 0.2s ease-out;
}
.change-list {
list-style: none;
margin-bottom: 1rem;
&__item {
// & + & {
// margin-top: 0.25rem;
// }
}
&__link {
width: 100%;
color: inherit;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__type {
}
&__filename {
}
&__path {
font-size: 0.9em;
}
&__actions {
flex: none;
}
&__action {
@media (min-width: 640px) {
.button-container {
max-width: 100%;
}
}
.commit-stashed {
display: flex;
gap: 0.25rem 0.5rem;
justify-content: start;
align-items: center;
&__media {
width: 3.6rem;
height: 3.6rem;
display: flex;
justify-content: center;
align-items: center;
}
&__media &__icon {
width: 2.8rem;
height: 2.8rem;
font-size: 2.8rem;
}
&__date {
font-size: 1.2rem;
}
.button-group {
display: inline-flex;
gap: 0.1rem;
width: 100%;
max-width: 30rem;
}
.commit-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: 1rem;
gap: 0.4rem;
font-size: 1.2rem;
border-radius: 0.3rem;
margin: 1rem;
.section {
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width);
.vscode-high-contrast &,
.vscode-dark & {
background-color: var(--color-background--lighten-075);
> :first-child {
margin-top: 0;
}
.vscode-high-contrast-light &,
.vscode-light & {
background-color: var(--color-background--darken-075);
> :last-child {
margin-bottom: 0;
}
}
&__message {
flex-basis: 60%;
margin: {
left: 0.6rem;
right: 0.6rem;
}
h2 {
font-weight: normal;
font-size: inherit;
margin: {
top: 0;
bottom: 0.4rem;
}
}
p {
margin: 0;
opacity: 0.5;
transition: font-size ease 100ms;
@media (max-width: 350px) {
font-size: 0.88em;
}
}
.section--message {
padding: {
top: 1rem;
bottom: 1.75rem;
}
}
&__media {
min-width: 10rem;
flex-basis: 40%;
max-width: 12rem;
margin-right: 0.6rem;
.section--empty {
> :last-child {
margin-top: 0.5rem;
}
}
&__icon {
flex: none;
&:last-child {
transform: translateY(-0.4rem);
}
.section--skeleton {
padding: {
top: 1px;
bottom: 1px;
}
}
@ -427,19 +246,52 @@ ul {
}
}
.commit-details {
&__top {
position: sticky;
top: 0;
z-index: 1;
padding: {
top: 0.1rem;
left: var(--gitlens-gutter-width);
right: var(--gitlens-scrollbar-gutter-width);
bottom: 0.5rem;
.change-list {
margin-bottom: 1rem;
}
.gl-actionbar {
}
.gl-actionbar__group {
}
.gl-action {
}
.message-block {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
padding: 0.5rem;
&__text {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 9rem;
> * {
white-space: break-spaces;
}
strong {
font-weight: 600;
font-size: 1.4rem;
}
background-color: var(--vscode-sideBar-background);
}
}
.top-details {
position: sticky;
top: 0;
z-index: 1;
padding: {
top: 0.1rem;
left: var(--gitlens-gutter-width);
right: var(--gitlens-scrollbar-gutter-width);
bottom: 0.5rem;
}
background-color: var(--vscode-sideBar-background);
&__actionbar {
display: flex;
@ -465,35 +317,6 @@ ul {
}
}
&__message {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
padding: 0.5rem;
margin: {
top: 1rem;
left: var(--gitlens-gutter-width);
right: var(--gitlens-scrollbar-gutter-width);
bottom: 1.75rem;
}
}
&__message-text {
flex: 1;
margin: 0;
display: block;
overflow-y: auto;
overflow-x: hidden;
max-height: 9rem;
white-space: break-spaces;
strong {
font-weight: 600;
font-size: 1.4rem;
}
}
&__sha {
margin: 0 0.5rem 0 0.25rem;
}
@ -502,55 +325,19 @@ ul {
flex-basis: 100%;
padding-top: 0.5rem;
}
&__author {
& + & {
margin-top: 0.5rem;
}
}
}
&__rich {
padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width);
> :first-child {
margin-top: 0;
}
> :last-child {
margin-top: 0.5rem;
margin-bottom: 0;
}
}
&__pull-request {
}
&__issue {
> :not(:first-child) {
margin-top: 0.5rem;
}
}
&__file {
--tree-level: 1;
padding: {
left: calc(var(--gitlens-gutter-width) * var(--tree-level));
right: var(--gitlens-scrollbar-gutter-width);
top: 1px;
bottom: 1px;
}
line-height: 22px;
height: 22px;
}
&__item-skeleton {
padding: {
left: var(--gitlens-gutter-width);
right: var(--gitlens-scrollbar-gutter-width);
top: 1px;
bottom: 1px;
}
}
.issue > :not(:first-child) {
margin-top: 0.5rem;
}
.commit-detail-panel {
$block: &;
max-height: 100vh;
overflow: auto;
scrollbar-gutter: stable;
@ -560,17 +347,6 @@ ul {
[aria-hidden='true'] {
display: none;
}
&__none {
padding: {
left: var(--gitlens-gutter-width);
right: var(--gitlens-scrollbar-gutter-width);
}
}
&__main {
// padding-bottom: 1rem;
}
}
.ai-content {
@ -602,6 +378,3 @@ ul {
}
}
}
@import '../shared/codicons';
@import '../shared/glicons';

+ 20
- 567
src/webviews/apps/commitDetails/commitDetails.ts View File

@ -1,7 +1,5 @@
/*global*/
import { ViewFilesLayout } from '../../../config';
import type { HierarchicalItem } from '../../../system/array';
import { makeHierarchical } from '../../../system/array';
import type { Serialized } from '../../../system/serialize';
import type { CommitActionsParams, State } from '../../commitDetails/protocol';
import {
@ -11,7 +9,6 @@ import {
DidExplainCommitCommandType,
ExplainCommitCommandType,
FileActionsCommandType,
messageHeadlineSplitterToken,
NavigateCommitCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
@ -28,6 +25,7 @@ import { App } from '../shared/appBase';
import type { FileChangeListItem, FileChangeListItemDetail } from '../shared/components/list/file-change-list-item';
import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../shared/components/webview-pane';
import { DOM } from '../shared/dom';
import type { GlCommitDetailsApp } from './components/commit-details-app';
import './commitDetails.scss';
import '../shared/components/actions/action-item';
import '../shared/components/actions/action-nav';
@ -42,17 +40,18 @@ import '../shared/components/progress';
import '../shared/components/list/list-container';
import '../shared/components/list/list-item';
import '../shared/components/list/file-change-list-item';
import './components/commit-details-app';
const uncommittedSha = '0000000000000000000000000000000000000000';
export const uncommittedSha = '0000000000000000000000000000000000000000';
type CommitState = SomeNonNullable<Serialized<State>, 'selected'>;
export type CommitState = SomeNonNullable<Serialized<State>, 'selected'>;
export class CommitDetailsApp extends App<Serialized<State>> {
constructor() {
super('CommitDetailsApp');
}
override onInitialize() {
this.renderContent();
this.attachState();
}
override onBind() {
@ -126,7 +125,7 @@ export class CommitDetailsApp extends App> {
this.state = params.state;
this.setState(this.state);
this.renderContent();
this.attachState();
});
break;
@ -146,14 +145,7 @@ export class CommitDetailsApp extends App> {
this.onCommandClickedCore('gitlens.switchAIModel');
}
async onExplainCommit(e: MouseEvent) {
const el = e.target as HTMLButtonElement;
if (el.getAttribute('aria-busy') === 'true') return;
el.setAttribute('aria-busy', 'true');
e.preventDefault();
const explanationEL = document.querySelector('[data-region="commit-explanation"]')!;
async onExplainCommit(_e: MouseEvent) {
try {
const result = await this.sendCommandWithCompletion(
ExplainCommitCommandType,
@ -161,22 +153,16 @@ export class CommitDetailsApp extends App> {
DidExplainCommitCommandType,
);
explanationEL.classList.toggle('has-error', result.error != null);
if (result.error) {
explanationEL.innerHTML = `<p class="ai-content__summary scrollable">${
result.error.message ?? 'Error retrieving content'
}</p>`;
this.component.explain = { error: { message: result.error.message ?? 'Error retrieving content' } };
} else if (result.summary) {
explanationEL.innerHTML = `<p class="ai-content__summary scrollable">${result.summary}</p>`;
this.component.explain = { summary: result.summary };
} else {
explanationEL.innerHTML = '';
this.component.explain = undefined;
this.component.explainBusy = false;
}
} catch (ex) {
explanationEL.classList.add('has-error');
explanationEL.innerHTML = `<p class="ai-content__summary scrollable">Error retrieving content</p>`;
} finally {
el.removeAttribute('aria-busy');
explanationEL.scrollIntoView();
this.component.explain = { error: { message: 'Error retrieving content' } };
}
}
@ -197,7 +183,7 @@ export class CommitDetailsApp extends App> {
files: files,
};
this.renderFiles(this.state as CommitState);
this.attachState();
this.sendCommand(PreferencesCommandType, { files: files });
}
@ -267,552 +253,19 @@ export class CommitDetailsApp extends App> {
this.sendCommand(CommitActionsCommandType, { action: action as CommitActionsParams['action'], alt: e.altKey });
}
renderCommit(state: Serialized<State>): state is CommitState {
const hasSelection = state.selected !== undefined;
const $empty = document.getElementById('empty');
const $main = document.getElementById('main');
$empty?.setAttribute('aria-hidden', hasSelection ? 'true' : 'false');
$main?.setAttribute('aria-hidden', hasSelection ? 'false' : 'true');
return hasSelection;
}
renderRichContent() {
if (!this.renderCommit(this.state)) return;
this.renderMessage(this.state);
this.renderPullRequestAndAutolinks(this.state);
}
renderContent() {
if (!this.renderCommit(this.state)) return;
this.renderActions(this.state);
this.renderNavigation(this.state);
this.renderPin(this.state);
this.renderSha(this.state);
this.renderMessage(this.state);
this.renderAuthor(this.state);
this.renderStats(this.state);
this.renderFiles(this.state);
// if (this.state.includeRichContent) {
this.renderPullRequestAndAutolinks(this.state);
// }
this.resetExplainCommit();
}
resetExplainCommit() {
const $explainPanel = document.querySelector('[data-region="explain-pane"]')!;
$explainPanel.removeAttribute('expanded');
$explainPanel.querySelector('button')!.removeAttribute('aria-busy');
const $explanation = $explainPanel.querySelector('[data-region="commit-explanation"]')!;
$explanation.classList.remove('has-error');
$explanation.innerHTML = '';
}
renderActions(state: CommitState) {
const isUncommitted = state.selected?.sha === uncommittedSha;
const isHiddenForUncommitted = isUncommitted.toString();
for (const $el of document.querySelectorAll('[data-action-type="sha"],[data-action-type="more"]')) {
$el.setAttribute('aria-hidden', isHiddenForUncommitted);
}
document.querySelector('[data-action-type="scm"]')?.setAttribute('aria-hidden', (!isUncommitted).toString());
}
renderNavigation(state: CommitState) {
const $back = document.querySelector<HTMLElement>('[data-action="back"]');
const $forward = document.querySelector<HTMLElement>('[data-action="forward"]');
if ($back == null || $forward == null) return;
if (state.navigationStack.count <= 1) {
$back.setAttribute('aria-disabled', 'true');
$back.classList.toggle('is-disabled', true);
$forward.setAttribute('aria-hidden', 'true');
$forward.classList.toggle('is-hidden', true);
} else if (state.navigationStack.position === 0) {
$back.setAttribute('aria-disabled', 'false');
$back.classList.toggle('is-disabled', false);
$forward.setAttribute('aria-hidden', 'true');
$forward.classList.toggle('is-hidden', true);
} else if (state.navigationStack.position === state.navigationStack.count - 1) {
$back.setAttribute('aria-disabled', 'true');
$back.classList.toggle('is-disabled', true);
$forward.setAttribute('aria-hidden', 'false');
$forward.classList.toggle('is-hidden', false);
} else {
$back.setAttribute('aria-disabled', 'false');
$back.classList.toggle('is-disabled', false);
$forward.setAttribute('aria-hidden', 'false');
$forward.classList.toggle('is-hidden', false);
}
const $hint = document.querySelector<HTMLElement>('[data-region="commit-hint"]');
if ($hint == null) return;
const $hintAction = $hint.closest('.commit-action')!;
if (state.navigationStack.hint) {
$hint.innerText = state.navigationStack.hint;
$hintAction.setAttribute('aria-hidden', 'false');
$hintAction.classList.toggle('is-hidden', false);
$hintAction.setAttribute('data-action', state.pinned ? 'forward' : 'back');
} else {
$hint.innerText = '';
$hintAction.removeAttribute('data-action');
$hintAction.removeAttribute('title');
$hintAction.setAttribute('aria-hidden', 'true');
$hintAction.classList.toggle('is-hidden', true);
}
}
renderPin(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-action="pin"]');
if ($el == null) return;
const label = state.pinned
? 'Unpin this Commit\nRestores Automatic Following'
: 'Pin this Commit\nSuspends Automatic Following';
$el.setAttribute('aria-label', label);
$el.setAttribute('title', label);
$el.classList.toggle('is-active', state.pinned);
$el.closest('.commit-details__actionbar')?.classList.toggle('is-pinned', state.pinned);
const $icon = $el.querySelector('[data-region="commit-pin"]');
$icon?.setAttribute('icon', state.pinned ? 'gl-pinned-filled' : 'pin');
}
renderSha(state: CommitState) {
const $els = [...document.querySelectorAll<HTMLElement>('[data-region="shortsha"]')];
if ($els.length === 0) return;
for (const $el of $els) {
$el.textContent = state.selected.shortSha;
}
}
renderChoices() {
// <nav class="commit-detail-panel__nav" aria-label="list of selected commits" data-region="choices">
// <p class="commit-detail-panel__commit-count">
// Selected commits: <span data-region="choices-count">2</span>
// </p>
// <ul class="commit-detail-panel__commits" data-region="choices-list">
// <li class="commit-detail-panel__commit">
// <skeleton-loader></skeleton-loader>
// </li>
// <li class="commit-detail-panel__commit">
// <skeleton-loader></skeleton-loader>
// </li>
// </ul>
// </nav>
const $el = document.querySelector<HTMLElement>('[data-region="choices"]');
if ($el == null) return;
// if (this.state.commits?.length) {
// const $count = $el.querySelector<HTMLElement>('[data-region="choices-count"]');
// if ($count != null) {
// $count.innerHTML = `${this.state.commits.length}`;
// }
// const $list = $el.querySelector<HTMLElement>('[data-region="choices-list"]');
// if ($list != null) {
// $list.innerHTML = this.state.commits
// .map(
// (item: CommitSummary) => `
// <li class="commit-detail-panel__commit">
// <button class="commit-detail-panel__commit-button" type="button" ${
// item.sha === this.state.selected?.sha ? 'aria-current="true"' : ''
// }>
// <img src="${item.avatar}" alt="${item.author.name}" />
// <span>${item.message}</span>
// <span>${item.shortSha}</span>
// </button>
// </li>
// `,
// )
// .join('');
// }
// $el.setAttribute('aria-hidden', 'false');
// } else {
$el.setAttribute('aria-hidden', 'true');
$el.innerHTML = '';
// }
}
renderStats(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="stats"]');
if ($el == null) return;
if (state.selected.stats?.changedFiles == null) {
$el.innerHTML = '';
return;
}
if (typeof state.selected.stats.changedFiles === 'number') {
$el.innerHTML = /*html*/ `
<commit-stats added="?" modified="${state.selected.stats.changedFiles}" removed="?"></commit-stats>
`;
} else {
const { added, deleted, changed } = state.selected.stats.changedFiles;
$el.innerHTML = /*html*/ `
<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>
`;
}
}
renderFiles(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="files"]');
if ($el == null) return;
const layout = state.preferences?.files?.layout ?? ViewFilesLayout.Auto;
const $toggle = document.querySelector('[data-switch-value]');
if ($toggle) {
switch (layout) {
case ViewFilesLayout.Auto:
$toggle.setAttribute('data-switch-value', 'list');
$toggle.setAttribute('icon', 'list-flat');
$toggle.setAttribute('label', 'View as List');
break;
case ViewFilesLayout.List:
$toggle.setAttribute('data-switch-value', 'tree');
$toggle.setAttribute('icon', 'list-tree');
$toggle.setAttribute('label', 'View as Tree');
break;
case ViewFilesLayout.Tree:
$toggle.setAttribute('data-switch-value', 'auto');
$toggle.setAttribute('icon', 'gl-list-auto');
$toggle.setAttribute('label', 'View as Auto');
break;
}
}
if (!state.selected.files?.length) {
$el.innerHTML = '';
return;
}
let isTree: boolean;
if (layout === ViewFilesLayout.Auto) {
isTree = state.selected.files.length > (state.preferences?.files?.threshold ?? 5);
} else {
isTree = layout === ViewFilesLayout.Tree;
}
const stashAttr =
state.selected.stashNumber != null ? 'stash ' : state.selected.sha === uncommittedSha ? 'uncommitted ' : '';
if (isTree) {
const tree = makeHierarchical(
state.selected.files,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
this.state.preferences?.files?.compact ?? true,
);
const flatTree = flattenHeirarchy(tree);
$el.innerHTML = `
<li class="change-list__item">
<list-container class="indentGuides-${state.indentGuides}">
${flatTree
.map(({ level, item }) => {
if (item.name === '') {
return '';
}
if (item.value == null) {
return /*html*/ `
<list-item level="${level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return /*html*/ `
<file-change-list-item
tree
level="${level}"
${stashAttr}
path="${item.value.path}"
repo="${item.value.repoPath}"
icon="${item.value.icon.dark}"
status="${item.value.status}"
></file-change-list-item>
`;
})
.join('')}
</list-container>
</li>`;
} else {
$el.innerHTML = /*html*/ `
<li class="change-list__item">
<list-container>
${state.selected.files
.map(
(file: Record<string, any>) => /*html*/ `
<file-change-list-item
${stashAttr}
path="${file.path}"
repo="${file.repoPath}"
icon="${file.icon.dark}"
status="${file.status}"
></file-change-list-item>
`,
)
.join('')}
</list-container>
</li>`;
}
$el.setAttribute('aria-hidden', 'false');
}
renderAuthor(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="author"]');
if ($el == null) return;
if (state.selected?.stashNumber != null) {
$el.innerHTML = /*html*/ `
<div class="commit-stashed">
<span class="commit-stashed__media"><code-icon class="commit-stashed__icon" icon="inbox"></code-icon></span>
<span class="commit-stashed__date">stashed <formatted-date date=${state.selected.author.date} dateFormat="${state.dateFormat}"></formatted-date></span>
</div>
`;
$el.setAttribute('aria-hidden', 'false');
} else if (state.selected?.author != null) {
$el.innerHTML = /*html*/ `
<commit-identity
name="${state.selected.author.name}"
email="${state.selected.author.email}"
date=${state.selected.author.date}
dateFormat="${state.dateFormat}"
avatarUrl="${state.selected.author.avatar ?? ''}"
showAvatar="${state.preferences?.avatars ?? true}"
actionLabel="${state.selected.sha === uncommittedSha ? 'modified' : 'committed'}"
></commit-identity>
`;
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
// renderCommitter(state: CommitState) {
// // <li class="commit-details__author" data-region="committer">
// // <skeleton-loader></skeleton-loader>
// // </li>
// const $el = document.querySelector<HTMLElement>('[data-region="committer"]');
// if ($el == null) {
// return;
// }
// if (state.selected.committer != null) {
// $el.innerHTML = `
// <commit-identity
// name="${state.selected.committer.name}"
// email="${state.selected.committer.email}"
// date="${state.selected.committer.date}"
// avatar="${state.selected.committer.avatar}"
// committer
// ></commit-identity>
// `;
// $el.setAttribute('aria-hidden', 'false');
// } else {
// $el.innerHTML = '';
// $el.setAttribute('aria-hidden', 'true');
// }
// }
renderTitle(state: CommitState) {
// <header class="commit-detail-panel__header" role="banner" aria-hidden="true">
// <h1 class="commit-detail-panel__title">
// <span class="codicon codicon-git-commit commit-detail-panel__title-icon"></span>
// Commit: <span data-region="commit-title"></span>
// </h1>
// </header>
const $el = document.querySelector<HTMLElement>('[data-region="commit-title"]');
if ($el == null) return;
$el.innerHTML = state.selected.shortSha;
}
renderMessage(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="message"]');
if ($el == null) return;
const index = state.selected.message.indexOf(messageHeadlineSplitterToken);
if (index === -1) {
$el.innerHTML = /*html*/ `<strong>${state.selected.message}</strong>`;
} else {
$el.innerHTML = /*html*/ `<strong>${state.selected.message.substring(
0,
index,
)}</strong><br />${state.selected.message.substring(index + 3)}`;
private _component?: GlCommitDetailsApp;
private get component() {
if (this._component == null) {
this._component = (document.getElementById('app') as GlCommitDetailsApp)!;
}
return this._component;
}
renderPullRequestAndAutolinks(state: CommitState) {
const $el = document.querySelector<WebviewPane>('[data-region="rich-pane"]');
if ($el == null) return;
$el.expanded = this.state.preferences?.autolinksExpanded ?? true;
$el.loading = !state.includeRichContent;
const $info = $el.querySelector('[data-region="rich-info"]');
const $autolinks = $el.querySelector('[data-region="autolinks"]');
const autolinkedIssuesCount = state.autolinkedIssues?.length ?? 0;
let autolinksCount = state.selected.autolinks?.length ?? 0;
let count = autolinksCount;
if (state.pullRequest != null || autolinkedIssuesCount || autolinksCount) {
let dedupedAutolinks = state.selected.autolinks;
if (dedupedAutolinks?.length && autolinkedIssuesCount) {
dedupedAutolinks = dedupedAutolinks.filter(
autolink => !state.autolinkedIssues?.some(issue => issue.url === autolink.url),
);
}
$autolinks?.setAttribute('aria-hidden', 'false');
$info?.setAttribute('aria-hidden', 'true');
this.renderAutolinks({
...state,
selected: {
...state.selected,
autolinks: dedupedAutolinks,
},
});
this.renderPullRequest(state);
this.renderIssues(state);
autolinksCount = dedupedAutolinks?.length ?? 0;
count = (state.pullRequest != null ? 1 : 0) + autolinkedIssuesCount + autolinksCount;
} else {
$autolinks?.setAttribute('aria-hidden', 'true');
$info?.setAttribute('aria-hidden', 'false');
}
const $count = $el.querySelector('[data-region="autolink-count"]');
if ($count == null) return;
$count.innerHTML = `${state.includeRichContent || autolinksCount ? `${count} found ` : ''}${
state.includeRichContent ? '' : '…'
}`;
}
renderAutolinks(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="custom-autolinks"]');
if ($el == null) return;
if (state.selected.autolinks?.length) {
$el.innerHTML = state.selected.autolinks
.map(autolink => {
let name = autolink.description ?? autolink.title;
if (name === undefined) {
name = `Custom Autolink ${autolink.prefix}${autolink.id}`;
}
return /*html*/ `
<issue-pull-request
name="${name ? escapeHTMLString(name) : ''}"
url="${autolink.url}"
key="${autolink.prefix}${autolink.id}"
status=""
></issue-pull-request>
`;
})
.join('');
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
renderPullRequest(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="pull-request"]');
if ($el == null) return;
if (state.pullRequest != null) {
$el.innerHTML = /*html*/ `
<issue-pull-request
name="${escapeHTMLString(state.pullRequest.title)}"
url="${state.pullRequest.url}"
key="#${state.pullRequest.id}"
status="${state.pullRequest.state}"
date=${state.pullRequest.date}
dateFormat="${state.dateFormat}"
></issue-pull-request>
`;
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
}
renderIssues(state: CommitState) {
const $el = document.querySelector<HTMLElement>('[data-region="issue"]');
if ($el == null) return;
if (state.autolinkedIssues?.length) {
$el.innerHTML = state.autolinkedIssues
.map(
issue => /*html*/ `
<issue-pull-request
name="${escapeHTMLString(issue.title)}"
url="${issue.url}"
key="${issue.id}"
status="${issue.closed ? 'closed' : 'opened'}"
date="${issue.closed ? issue.closedDate : issue.date}"
></issue-pull-request>
`,
)
.join('');
$el.setAttribute('aria-hidden', 'false');
} else {
$el.innerHTML = '';
$el.setAttribute('aria-hidden', 'true');
}
attachState() {
this.component.state = this.state;
}
}
function assertsSerialized<T>(obj: unknown): asserts obj is Serialized<T> {}
function flattenHeirarchy<T>(item: HierarchicalItem<T>, level = 0): { level: number; item: HierarchicalItem<T> }[] {
const flattened: { level: number; item: HierarchicalItem<T> }[] = [];
if (item == null) return flattened;
flattened.push({ level: level, item: item });
if (item.children != null) {
const children = Array.from(item.children.values());
children.sort((a, b) => {
if (!a.value || !b.value) {
return (a.value ? 1 : -1) - (b.value ? 1 : -1);
}
if (a.relativePath < b.relativePath) {
return -1;
}
if (a.relativePath > b.relativePath) {
return 1;
}
return 0;
});
children.forEach(child => {
flattened.push(...flattenHeirarchy(child, level + 1));
});
}
return flattened;
}
function escapeHTMLString(value: string) {
return value.replace(/"/g, '&quot;');
}
new CommitDetailsApp();

+ 659
- 0
src/webviews/apps/commitDetails/components/commit-details-app.ts View File

@ -0,0 +1,659 @@
import { html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { when } from 'lit/directives/when.js';
import { ViewFilesLayout } from '../../../../config';
import type { HierarchicalItem } from '../../../../system/array';
import { makeHierarchical } from '../../../../system/array';
import type { Serialized } from '../../../../system/serialize';
import type { State } from '../../../commitDetails/protocol';
import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol';
import { uncommittedSha } from '../commitDetails';
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
summary?: string;
}
@customElement('gl-commit-details-app')
export class GlCommitDetailsApp extends LitElement {
@property({ type: Object })
state?: Serialized<State>;
@state()
explainBusy = false;
@property({ type: Object })
explain?: ExplainState;
get isUncommitted() {
return this.state?.selected?.sha === uncommittedSha;
}
get isStash() {
return this.state?.selected?.stashNumber != null;
}
get shortSha() {
return this.state?.selected?.shortSha ?? '';
}
get navigation() {
if (this.state?.navigationStack == null) {
return {
back: false,
forward: false,
};
}
const actions = {
back: true,
forward: true,
};
if (this.state.navigationStack.count <= 1) {
actions.back = false;
actions.forward = false;
} else if (this.state.navigationStack.position === 0) {
actions.back = true;
actions.forward = false;
} else if (this.state.navigationStack.position === this.state.navigationStack.count - 1) {
actions.back = false;
actions.forward = true;
}
return actions;
}
override updated(changedProperties: Map<string, any>) {
if (changedProperties.has('explain')) {
this.explainBusy = false;
this.querySelector('[data-region="commit-explanation"]')?.scrollIntoView();
}
}
private renderEmptyContent() {
return html`
<div class="section section--empty" id="empty">
<p>Rich details for commits and stashes are shown as you navigate:</p>
<ul class="bulleted">
<li>lines in the text editor</li>
<li>
commits in the <a href="command:gitlens.showGraph">Commit Graph</a>,
<a href="command:gitlens.showTimelineView">Visual File History</a>, or
<a href="command:gitlens.showCommitsView">Commits view</a>
</li>
<li>stashes in the <a href="command:gitlens.showStashesView">Stashes view</a></li>
</ul>
<p>Alternatively, search for or choose a commit</p>
<p class="button-container">
<span class="button-group">
<button class="button button--full" type="button" data-action="pick-commit">
Choose Commit...
</button>
<button
class="button"
type="button"
data-action="search-commit"
aria-label="Search for Commit"
title="Search for Commit"
>
<code-icon icon="search"></code-icon>
</button>
</span>
</p>
</div>
`;
}
private renderCommitMessage() {
if (this.state?.selected == null) {
return undefined;
}
const message = this.state.selected.message;
const index = message.indexOf(messageHeadlineSplitterToken);
return html`
<div class="section section--message">
<div class="message-block">
${when(
index === -1,
() =>
html`<p class="message-block__text scrollable" data-region="message">
<strong>${unsafeHTML(message)}</strong>
</p>`,
() =>
html`<p class="message-block__text scrollable" data-region="message">
<strong>${unsafeHTML(message.substring(0, index))}</strong><br /><span
>${unsafeHTML(message.substring(index + 3))}</span
>
</p>`,
)}
</div>
</div>
`;
}
private renderAutoLinks() {
if (this.isUncommitted) {
return undefined;
}
const autolinkedIssuesCount = this.state?.autolinkedIssues?.length ?? 0;
let autolinksCount = this.state?.selected?.autolinks?.length ?? 0;
let count = autolinksCount;
const hasPullRequest = this.state?.pullRequest != null;
const hasAutolinks = hasPullRequest || autolinkedIssuesCount > 0 || autolinksCount > 0;
let dedupedAutolinks = this.state?.selected?.autolinks;
if (hasAutolinks) {
if (dedupedAutolinks?.length && autolinkedIssuesCount) {
dedupedAutolinks = dedupedAutolinks.filter(
autolink => !this.state?.autolinkedIssues?.some(issue => issue.url === autolink.url),
);
autolinksCount = dedupedAutolinks?.length ?? 0;
count = (hasPullRequest ? 1 : 0) + autolinkedIssuesCount + autolinksCount;
}
}
return html`
<webview-pane
collapsable
?expanded=${this.state?.preferences?.autolinksExpanded ?? true}
?loading=${!this.state?.includeRichContent}
data-region="rich-pane"
>
<span slot="title">Autolinks</span>
<span slot="subtitle" data-region="autolink-count"
>${this.state?.includeRichContent || autolinksCount ? `${count} found ` : ''}${this.state
?.includeRichContent
? ''
: '…'}</span
>
${when(
this.state == null,
() => html`
<div class="section" data-region="autolinks">
<section class="auto-link" aria-label="Custom Autolinks" data-region="custom-autolinks">
<skeleton-loader lines="2"></skeleton-loader>
</section>
<section class="pull-request" aria-label="Pull request" data-region="pull-request">
<skeleton-loader lines="2"></skeleton-loader>
</section>
<section class="issue" aria-label="Issue" data-region="issue">
<skeleton-loader lines="2"></skeleton-loader>
</section>
</div>
`,
() => {
if (!hasAutolinks || count === 0) {
return html`
<div class="section" data-region="rich-info">
<p>
<code-icon icon="info"></code-icon>&nbsp;Use
<a href="#" data-action="autolink-settings" title="Configure autolinks"
>autolinks</a
>
to linkify external references, like Jira issues or Zendesk tickets, in commit
messages.
</p>
</div>
`;
}
return html`
<div class="section" data-region="autolinks">
${dedupedAutolinks != null && dedupedAutolinks.length > 0
? html`
<section
class="auto-link"
aria-label="Custom Autolinks"
data-region="custom-autolinks"
>
${dedupedAutolinks.map(autolink => {
let name = autolink.description ?? autolink.title;
if (name === undefined) {
name = `Custom Autolink ${autolink.prefix}${autolink.id}`;
}
return html`
<issue-pull-request
name="${name}"
url="${autolink.url}"
key="${autolink.prefix}${autolink.id}"
status=""
></issue-pull-request>
`;
})}
</section>
`
: undefined}
${hasPullRequest
? html`
<section
class="pull-request"
aria-label="Pull request"
data-region="pull-request"
>
<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}
dateFormat="${this.state!.dateFormat}"
></issue-pull-request>
</section>
`
: undefined}
${this.state?.autolinkedIssues?.length
? html`
<section class="issue" aria-label="Issue" data-region="issue">
${this.state.autolinkedIssues.map(
issue => html`
<issue-pull-request
name="${issue.title}"
url="${issue.url}"
key="${issue.id}"
status="${issue.closed ? 'closed' : 'opened'}"
date="${issue.closed ? issue.closedDate : issue.date}"
></issue-pull-request>
`,
)}
</section>
`
: undefined}
</div>
`;
},
)}
</webview-pane>
`;
}
private renderExplainAi() {
// TODO: add loading and response states
return html`
<webview-pane collapsable data-region="explain-pane">
<span slot="title">Explain (AI)</span>
<span slot="subtitle"><code-icon icon="beaker" size="12"></code-icon></span>
<action-nav slot="actions">
<action-item data-action="switch-ai" label="Switch AI Model" icon="hubot"></action-item>
</action-nav>
<div class="section">
<p>Let AI assist in understanding the changes made with this commit.</p>
<p class="button-container">
<span class="button-group">
<button
class="button button--full button--busy"
type="button"
data-action="explain-commit"
aria-busy="${this.explainBusy ? 'true' : nothing}"
@click=${this.onExplainChanges}
@keydown=${this.onExplainChanges}
>
<code-icon icon="loading" modifier="spin"></code-icon>Explain this Commit
</button>
</span>
</p>
${when(
this.explain,
() => html`
<div
class="ai-content${this.explain?.error ? ' has-error' : ''}"
data-region="commit-explanation"
>
${when(
this.explain?.error,
() =>
html`<p class="ai-content__summary scrollable">
${this.explain!.error!.message ?? 'Error retrieving content'}
</p>`,
)}
${when(
this.explain?.summary,
() => html`<p class="ai-content__summary scrollable">${this.explain!.summary}</p>`,
)}
</div>
`,
)}
</div>
</webview-pane>
`;
}
private renderCommitStats() {
if (this.state?.selected?.stats?.changedFiles == null) {
return undefined;
}
if (typeof this.state.selected.stats.changedFiles === 'number') {
return html`<commit-stats
added="?"
modified="${this.state.selected.stats.changedFiles}"
removed="?"
></commit-stats>`;
}
const { added, deleted, changed } = this.state.selected.stats.changedFiles;
return html`<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>`;
}
private renderFileList() {
return html`<list-container>
${this.state!.selected!.files!.map(
(file: Record<string, any>) => html`
<file-change-list-item
?stash=${this.isStash}
?uncommitted=${this.isUncommitted}
path="${file.path}"
repo="${file.repoPath}"
icon="${file.icon.dark}"
status="${file.status}"
></file-change-list-item>
`,
)}
</list-container>`;
}
private renderFileTree() {
const tree = makeHierarchical(
this.state!.selected!.files!,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
this.state!.preferences?.files?.compact ?? true,
);
const flatTree = flattenHeirarchy(tree);
return html`<list-container class="indentGuides-${this.state!.indentGuides}">
${flatTree.map(({ level, item }) => {
if (item.name === '') {
return undefined;
}
if (item.value == null) {
return html`
<list-item level="${level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return html`
<file-change-list-item
tree
level="${level}"
?stash=${this.isStash}
?uncommitted=${this.isUncommitted}
path="${item.value.path}"
repo="${item.value.repoPath}"
icon="${item.value.icon.dark}"
status="${item.value.status}"
></file-change-list-item>
`;
})}
</list-container>`;
}
private renderChangedFiles() {
const layout = this.state?.preferences?.files?.layout ?? ViewFilesLayout.Auto;
let value = 'tree';
let icon = 'list-tree';
let label = 'View as Tree';
let isTree = false;
if (this.state?.selected?.files != null) {
if (layout === ViewFilesLayout.Auto) {
isTree = this.state.selected.files.length > (this.state.preferences?.files?.threshold ?? 5);
} else {
isTree = layout === ViewFilesLayout.Tree;
}
switch (layout) {
case ViewFilesLayout.Auto:
value = 'list';
icon = 'list-flat';
label = 'View as List';
break;
case ViewFilesLayout.List:
value = 'tree';
icon = 'list-tree';
label = 'View as Tree';
break;
case ViewFilesLayout.Tree:
value = 'auto';
icon = 'gl-list-auto';
label = 'View as Auto';
break;
}
}
return html`
<webview-pane collapsable expanded>
<span slot="title">Files changed </span>
<span slot="subtitle" data-region="stats">${this.renderCommitStats()}</span>
<action-nav slot="actions">
<action-item data-switch-value="${value}" label="${label}" icon="${icon}"></action-item>
</action-nav>
<div class="change-list" data-region="files">
${when(
this.state?.selected?.files == null,
() => html`
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
`,
() => (isTree ? this.renderFileTree() : this.renderFileList()),
)}
</div>
</webview-pane>
`;
}
override render() {
if (this.state?.selected == null) {
return html` <div class="commit-detail-panel scrollable">${this.renderEmptyContent()}</div>`;
}
const pinLabel = this.state.pinned
? 'Unpin this Commit\nRestores Automatic Following'
: 'Pin this Commit\nSuspends Automatic Following';
return html`
<div class="commit-detail-panel scrollable">
<main id="main" tabindex="-1">
<div class="top-details">
<div class="top-details__top-menu">
<div class="top-details__actionbar${this.state.pinned ? ' is-pinned' : ''}">
<div class="top-details__actionbar-group">
<a
class="commit-action${this.state.pinned ? ' is-active' : ''}"
href="#"
data-action="pin"
aria-label="${pinLabel}"
title="${pinLabel}"
><code-icon
icon="${this.state.pinned ? 'gl-pinned-filled' : 'pin'}"
data-region="commit-pin"
></code-icon
></a>
<a
class="commit-action${this.navigation.back ? '' : ' is-disabled'}"
aria-disabled="${this.navigation.back ? nothing : 'true'}"
href="#"
data-action="back"
aria-label="Back"
title="Back"
><code-icon icon="arrow-left" data-region="commit-back"></code-icon
></a>
${when(
this.navigation.forward,
() => html`
<a
class="commit-action"
href="#"
data-action="forward"
aria-label="Forward"
title="Forward"
><code-icon icon="arrow-right" data-region="commit-forward"></code-icon
></a>
`,
)}
${when(
this.state.navigationStack.hint,
() => html`
<a
class="commit-action commit-action--emphasis-low"
href="#"
title="View this Commit"
data-action="${this.state!.pinned ? 'forward' : 'back'}"
><code-icon icon="git-commit"></code-icon
><span data-region="commit-hint"
>${this.state!.navigationStack.hint}</span
></a
>
`,
)}
</div>
<div class="top-details__actionbar-group">
${when(
!this.isUncommitted,
() => html`
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="sha"
aria-label="Copy SHA
[] Pick Commit..."
title="Copy SHA
[] Pick Commit..."
>
<code-icon icon="git-commit"></code-icon>
<span class="top-details__sha" data-region="shortsha"
>${this.shortSha}</span
></a
>
`,
() => html`
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="scm"
aria-label="Open SCM view"
title="Open SCM view"
><code-icon icon="source-control"></code-icon
></a>
`,
)}
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="graph"
aria-label="Open in Commit Graph"
title="Open in Commit Graph"
><code-icon icon="gl-graph"></code-icon
></a>
${when(
!this.isUncommitted,
() => html`
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="more"
aria-label="Show Commit Actions"
title="Show Commit Actions"
><code-icon icon="kebab-vertical"></code-icon
></a>
`,
)}
</div>
</div>
${when(
this.state.selected && this.state.selected.stashNumber == null,
() => html`
<ul class="top-details__authors" aria-label="Authors">
<li class="top-details__author" data-region="author">
<commit-identity
name="${this.state!.selected!.author.name}"
email="${this.state!.selected!.author.email}"
date=${this.state!.selected!.author.date}
dateFormat="${this.state!.dateFormat}"
avatarUrl="${this.state!.selected!.author.avatar ?? ''}"
showAvatar="${this.state!.preferences?.avatars ?? true}"
actionLabel="${this.state!.selected!.sha === uncommittedSha
? 'modified'
: 'committed'}"
></commit-identity>
</li>
</ul>
`,
)}
</div>
</div>
${this.renderCommitMessage()} ${this.renderAutoLinks()} ${this.renderChangedFiles()}
${this.renderExplainAi()}
</main>
</div>
`;
}
protected override createRenderRoot() {
return this;
}
onExplainChanges(e: MouseEvent | KeyboardEvent) {
if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) {
e.preventDefault();
e.stopPropagation();
return;
}
this.explainBusy = true;
}
}
function flattenHeirarchy<T>(item: HierarchicalItem<T>, level = 0): { level: number; item: HierarchicalItem<T> }[] {
const flattened: { level: number; item: HierarchicalItem<T> }[] = [];
if (item == null) return flattened;
flattened.push({ level: level, item: item });
if (item.children != null) {
const children = Array.from(item.children.values());
children.sort((a, b) => {
if (!a.value || !b.value) {
return (a.value ? 1 : -1) - (b.value ? 1 : -1);
}
if (a.relativePath < b.relativePath) {
return -1;
}
if (a.relativePath > b.relativePath) {
return 1;
}
return 0;
});
children.forEach(child => {
flattened.push(...flattenHeirarchy(child, level + 1));
});
}
return flattened;
}

+ 53
- 43
src/webviews/apps/shared/components/actions/action-item.ts View File

@ -1,48 +1,58 @@
import { attr, css, customElement, FASTElement, html } from '@microsoft/fast-element';
const template = html<ActionItem>`<a
role="${x => (!x.href ? 'button' : null)}"
type="${x => (!x.href ? 'button' : null)}"
aria-label="${x => x.label}"
title="${x => x.label}"
><code-icon icon="${x => x.icon}"></code-icon
></a>`;
const styles = css`
:host {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
color: inherit;
padding: 0.2rem;
vertical-align: text-bottom;
text-decoration: none;
cursor: pointer;
}
:host(:focus) {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
:host(:hover) {
background-color: var(--vscode-toolbar-hoverBackground);
}
:host(:active) {
background-color: var(--vscode-toolbar-activeBackground);
}
`;
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '../code-icon';
@customElement('action-item')
export class ActionItem extends LitElement {
static override styles = css`
:host {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
color: inherit;
padding: 0.2rem;
vertical-align: text-bottom;
text-decoration: none;
cursor: pointer;
}
@customElement({ name: 'action-item', template: template, styles: styles })
export class ActionItem extends FASTElement {
@attr
:host(:focus) {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
:host(:hover) {
background-color: var(--vscode-toolbar-hoverBackground);
}
:host(:active) {
background-color: var(--vscode-toolbar-activeBackground);
}
`;
@property()
href?: string;
@attr
label: string = '';
@property()
label = '';
@attr
icon: string = '';
@property()
icon = '';
overriderender() {
return html`
<a
role="${!this.href ? 'button' : nothing}"
type="${!this.href ? 'button' : nothing}"
aria-label="${this.label}"
title="${this.label}"
>
<code-icon .icon="${this.icon}"></code-icon>
</a>
`;
}
}

+ 62
- 57
src/webviews/apps/shared/components/commit/commit-identity.ts View File

@ -1,78 +1,83 @@
import { attr, css, customElement, FASTElement, html, when } from '@microsoft/fast-element';
import { css, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '../code-icon';
import '../formatted-date';
const template = html<CommitIdentity>`
<template>
<a class="avatar" href="${x => (x.email ? `mailto:${x.email}` : '#')}">
${when(
x => x.showAvatar,
html<CommitIdentity>`<img class="thumb" lazy src="${x => x.avatarUrl}" alt="${x => x.name}" />`,
)}
${when(x => !x.showAvatar, html<CommitIdentity>`<code-icon icon="person" size="32"></code-icon>`)}
</a>
<a class="name" href="${x => (x.email ? `mailto:${x.email}` : '#')}">${x => x.name}</a>
<span class="date"
>${x => x.actionLabel} <formatted-date date=${x => x.date} format="${x => x.dateFormat}"></formatted-date
></span>
</template>
`;
@customElement('commit-identity')
export class CommitIdentity extends LitElement {
static override styles = css`
:host {
display: grid;
gap: 0rem 1rem;
justify-content: start;
}
const styles = css`
:host {
display: grid;
gap: 0rem 1rem;
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.3rem;
}
`;
a {
color: var(--color-link-foreground);
text-decoration: none;
}
@customElement({ name: 'commit-identity', template: template, styles: styles })
export class CommitIdentity extends FASTElement {
@attr({ mode: 'reflect' })
.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.3rem;
}
`;
@property()
name = '';
@attr({ mode: 'reflect' })
@property()
email = '';
@attr({ mode: 'reflect' })
@property()
date = '';
@attr({ mode: 'reflect' })
@property()
avatarUrl = 'https://www.gravatar.com/avatar/?s=64&d=robohash';
@attr({ mode: 'boolean' })
@property({ type: Boolean })
showAvatar = false;
@attr({ mode: 'reflect' })
@property()
dateFormat = 'MMMM Do, YYYY h:mma';
@attr({ mode: 'boolean' })
@property()
committer = false;
@attr({ mode: 'reflect' })
@property()
actionLabel = 'committed';
override render() {
return html`
<a class="avatar" href="${this.email ? `mailto:${this.email}` : '#'}">
${this.showAvatar
? html`<img class="thumb" src="${this.avatarUrl}" alt="${this.name}" />`
: html`<code-icon icon="person" size="32"></code-icon>`}
</a>
<a class="name" href="${this.email ? `mailto:${this.email}` : '#'}">${this.name}</a>
<span class="date">
${this.actionLabel}
<formatted-date date=${this.date} format=${this.dateFormat}> </formatted-date>
</span>
`;
}
}

+ 70
- 67
src/webviews/apps/shared/components/progress.ts View File

@ -1,86 +1,89 @@
import { attr, css, customElement, FASTElement, html } from '@microsoft/fast-element';
const template = html<ProgressIndicator>`
<template class="${x => x.mode}${x => (x.active ? ' active' : '')}" role="progressbar">
<div class="progress-bar"></div>
</template>
`;
const styles = css`
* {
box-sizing: border-box;
}
:host {
position: absolute;
left: 0;
z-index: 5;
height: 2px;
width: 100%;
overflow: hidden;
}
:host([position='bottom']) {
bottom: 0;
}
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('progress-indicator')
export class ProgressIndicator extends LitElement {
static override styles = css`
* {
box-sizing: border-box;
}
:host([position='top']) {
top: 0;
}
:host {
position: absolute;
left: 0;
z-index: 5;
height: 2px;
width: 100%;
overflow: hidden;
}
.progress-bar {
background-color: var(--vscode-progressBar-background);
display: none;
position: absolute;
left: 0;
width: 2%;
height: 2px;
}
:host([position='bottom']) {
bottom: 0;
}
:host(.active) .progress-bar {
display: inherit;
}
:host([position='top']) {
top: 0;
}
:host(.discrete) .progress-bar {
left: 0;
transition: width 0.1s linear;
}
.progress-bar {
background-color: var(--vscode-progressBar-background);
display: none;
position: absolute;
left: 0;
width: 2%;
height: 2px;
}
:host(.discrete.done) .progress-bar {
width: 100%;
}
:host([active]:not([active='false'])) .progress-bar {
display: inherit;
}
:host(.infinite) .progress-bar {
animation-name: progress;
animation-duration: 4s;
animation-iteration-count: infinite;
animation-timing-function: steps(100);
transform: translateZ(0);
}
:host([mode='discrete']) .progress-bar {
left: 0;
transition: width 0.1s linear;
}
@keyframes progress {
0% {
transform: translateX(0) scaleX(1);
:host([mode='discrete done']) .progress-bar {
width: 100%;
}
50% {
transform: translateX(2500%) scaleX(3);
:host([mode='infinite']) .progress-bar {
animation-name: progress;
animation-duration: 4s;
animation-iteration-count: infinite;
animation-timing-function: steps(100);
transform: translateZ(0);
}
to {
transform: translateX(4900%) scaleX(1);
@keyframes progress {
0% {
transform: translateX(0) scaleX(1);
}
50% {
transform: translateX(2500%) scaleX(3);
}
to {
transform: translateX(4900%) scaleX(1);
}
}
}
`;
`;
@customElement({ name: 'progress-indicator', template: template, styles: styles })
export class ProgressIndicator extends FASTElement {
@attr({ mode: 'reflect' })
@property({ reflect: true })
mode = 'infinite';
@attr({ mode: 'boolean' })
@property({ type: Boolean })
active = false;
@attr()
@property()
position: 'top' | 'bottom' = 'bottom';
override firstUpdated() {
this.setAttribute('role', 'progressbar');
}
override render() {
return html`<div class="progress-bar"></div>`;
}
}

Loading…
Cancel
Save