Bläddra i källkod

Adds Working Changes tab to Commit/Graph Details

- Adds stage/unstage support
main
Eric Amodio 1 år sedan
förälder
incheckning
a70e2b930a
17 ändrade filer med 1727 tillägg och 944 borttagningar
  1. +2
    -0
      CHANGELOG.md
  2. +1
    -1
      src/trackers/gitLineTracker.ts
  3. +1
    -0
      src/trackers/lineTracker.ts
  4. +96
    -7
      src/webviews/apps/commitDetails/commitDetails.scss
  5. +48
    -23
      src/webviews/apps/commitDetails/commitDetails.ts
  6. +59
    -674
      src/webviews/apps/commitDetails/components/commit-details-app.ts
  7. +523
    -0
      src/webviews/apps/commitDetails/components/gl-commit-details.ts
  8. +235
    -0
      src/webviews/apps/commitDetails/components/gl-details-base.ts
  9. +67
    -0
      src/webviews/apps/commitDetails/components/gl-wip-details.ts
  10. +5
    -4
      src/webviews/apps/shared/components/commit/commit-identity.ts
  11. +52
    -31
      src/webviews/apps/shared/components/list/file-change-list-item.ts
  12. +0
    -1
      src/webviews/apps/shared/components/list/list-container.ts
  13. +2
    -0
      src/webviews/apps/shared/components/list/list-item.ts
  14. +237
    -0
      src/webviews/apps/shared/components/status/git-status.ts
  15. +348
    -170
      src/webviews/commitDetails/commitDetailsWebview.ts
  16. +49
    -31
      src/webviews/commitDetails/protocol.ts
  17. +2
    -2
      src/webviews/commitDetails/registration.ts

+ 2
- 0
CHANGELOG.md Visa fil

@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Added
- Adds a _Working Changes_ tab to the _Commit Details_ and _Graph Details_ views to show your working tree changes
- Adds _Stage Changes_ and _Unstage Changes_ commands to files on the _Working Changes_ tab
- Adds a _[Show|Hide] Merge Commits_ toggle to the _File History_ view — closes [#2104](https://github.com/gitkraken/vscode-gitlens/issues/2104) & [#2944](https://github.com/gitkraken/vscode-gitlens/issues/2944)
- Adds a `gitlens.advanced.fileHistoryShowMergeCommits` setting to specify whether merge commits will be show in file histories
- Adds deep link support for workspaces in the _GitKraken Workspaces_ view

+ 1
- 1
src/trackers/gitLineTracker.ts Visa fil

@ -31,7 +31,7 @@ export class GitLineTracker extends LineTracker {
updated = await this.updateState(e.selections, e.editor);
}
super.fireLinesChanged(updated ? e : { ...e, selections: undefined });
super.fireLinesChanged(updated ? e : { ...e, selections: undefined, suspended: this.suspended });
}
private _subscriptionOnlyWhenActive: Disposable | undefined;

+ 1
- 0
src/trackers/lineTracker.ts Visa fil

@ -13,6 +13,7 @@ export interface LinesChangeEvent {
readonly reason: 'editor' | 'selection';
readonly pending?: boolean;
readonly suspended?: boolean;
}
export interface LineSelection {

+ 96
- 7
src/webviews/apps/commitDetails/commitDetails.scss Visa fil

@ -5,6 +5,24 @@
--gitlens-scrollbar-gutter-width: 10px;
}
.vscode-high-contrast,
.vscode-dark {
--color-background--level-05: var(--color-background--lighten-05);
--color-background--level-075: var(--color-background--lighten-075);
--color-background--level-10: var(--color-background--lighten-10);
--color-background--level-15: var(--color-background--lighten-15);
--color-background--level-30: var(--color-background--lighten-30);
}
.vscode-high-contrast-light,
.vscode-light {
--color-background--level-05: var(--color-background--darken-05);
--color-background--level-075: var(--color-background--darken-075);
--color-background--level-10: var(--color-background--darken-10);
--color-background--level-15: var(--color-background--darken-15);
--color-background--level-30: var(--color-background--darken-30);
}
// generic resets
html {
font-size: 62.5%;
@ -19,6 +37,9 @@ html {
}
body {
--gk-badge-outline-color: var(--vscode-badge-foreground);
--gk-badge-filled-background-color: var(--vscode-badge-background);
--gk-badge-filled-color: var(--vscode-badge-foreground);
font-family: var(--font-family);
font-size: var(--font-size);
color: var(--color-foreground);
@ -250,13 +271,6 @@ ul {
margin-bottom: 1rem;
}
.gl-actionbar {
}
.gl-actionbar__group {
}
.gl-action {
}
.message-block {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
@ -305,6 +319,14 @@ ul {
flex: none;
}
&--highlight {
margin-left: 0.25em;
padding: 0 4px 2px 4px;
border: 1px solid var(--color-background--level-15);
border-radius: 0.3rem;
font-family: var(--vscode-editor-font-family);
}
&.is-pinned {
background-color: var(--color-alert-warningBackground);
box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder);
@ -378,3 +400,70 @@ ul {
}
}
}
.wip-details {
display: flex;
padding: 0.4rem 0.8rem;
background: var(--color-alert-infoBackground);
border-left: 0.3rem solid var(--color-alert-infoBorder);
align-items: center;
justify-content: space-between;
.wip-changes {
display: inline-flex;
align-items: baseline;
}
.wip-branch {
display: inline-block;
padding: 0 0.3rem 0.2rem;
margin-left: 0.4rem;
// background: var(--color-background--level-05);
border: 1px solid var(--color-foreground--50);
border-radius: 0.3rem;
}
gl-button {
padding: 0.2rem 0.8rem;
opacity: 0.8;
}
}
.details-tab {
display: flex;
justify-content: stretch;
align-items: center;
margin-bottom: 0.4rem;
gap: 0.2rem;
& > * {
flex: 1;
}
&__item {
appearance: none;
padding: 0.4rem;
color: var(--color-foreground--85);
background-color: transparent;
border: none;
border-bottom: 0.2rem solid transparent;
cursor: pointer;
// background-color: #00000030;
line-height: 1.8rem;
gk-badge {
line-height: 1.36rem;
}
&:hover {
color: var(--color-foreground);
// background-color: var(--vscode-button-hoverBackground);
background-color: #00000020;
}
&.is-active {
color: var(--color-foreground);
border-bottom-color: var(--vscode-button-hoverBackground);
}
}
}

+ 48
- 23
src/webviews/apps/commitDetails/commitDetails.ts Visa fil

@ -1,13 +1,14 @@
/*global*/
import type { ViewFilesLayout } from '../../../config';
import type { Serialized } from '../../../system/serialize';
import type { CommitActionsParams, State } from '../../commitDetails/protocol';
import type { CommitActionsParams, Mode, State } from '../../commitDetails/protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
DidExplainCommitCommandType,
ExplainCommitCommandType,
DidChangeWipStateNotificationType,
DidExplainCommandType,
ExplainCommandType,
FileActionsCommandType,
NavigateCommitCommandType,
OpenFileCommandType,
@ -16,8 +17,11 @@ import {
OpenFileOnRemoteCommandType,
PickCommitCommandType,
PinCommitCommandType,
PreferencesCommandType,
SearchCommitCommandType,
StageFileCommandType,
SwitchModeCommandType,
UnstageFileCommandType,
UpdatePreferencesCommandType,
} from '../../commitDetails/protocol';
import type { IpcMessage } from '../../protocol';
import { ExecuteCommandType, onIpc } from '../../protocol';
@ -44,7 +48,7 @@ import './components/commit-details-app';
export const uncommittedSha = '0000000000000000000000000000000000000000';
export type CommitState = SomeNonNullable<Serialized<State>, 'selected'>;
export type CommitState = SomeNonNullable<Serialized<State>, 'commit'>;
export class CommitDetailsApp extends App<Serialized<State>> {
constructor() {
super('CommitDetailsApp');
@ -71,8 +75,16 @@ export class CommitDetailsApp extends App> {
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-more-actions', e =>
this.onFileMoreActions(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-stage', e =>
this.onStageFile(e.detail),
),
DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-unstage', e =>
this.onUnstageFile(e.detail),
),
DOM.on('[data-action="commit-actions"]', 'click', e => this.onCommitActions(e)),
DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)),
DOM.on('[data-action="wip"]', 'click', e => this.onSwitchMode(e, 'wip')),
DOM.on('[data-action="details"]', 'click', e => this.onSwitchMode(e, 'commit')),
DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)),
DOM.on('[data-action="autolink-settings"]', 'click', e => this.onAutolinkSettings(e)),
DOM.on('[data-action="files-layout"]', 'click', e => this.onToggleFilesLayout(e)),
@ -129,6 +141,14 @@ export class CommitDetailsApp extends App> {
});
break;
case DidChangeWipStateNotificationType.method:
onIpc(DidChangeWipStateNotificationType, msg, params => {
this.state = { ...this.state, ...params };
this.setState(this.state);
this.attachState();
});
break;
default:
super.onMessageReceived?.(e);
}
@ -147,11 +167,7 @@ export class CommitDetailsApp extends App> {
async onExplainCommit(_e: MouseEvent) {
try {
const result = await this.sendCommandWithCompletion(
ExplainCommitCommandType,
undefined,
DidExplainCommitCommandType,
);
const result = await this.sendCommandWithCompletion(ExplainCommandType, undefined, DidExplainCommandType);
if (result.error) {
this.component.explain = { error: { message: result.error.message ?? 'Error retrieving content' } };
@ -159,7 +175,6 @@ export class CommitDetailsApp extends App> {
this.component.explain = { summary: result.summary };
} else {
this.component.explain = undefined;
this.component.explainBusy = false;
}
} catch (ex) {
this.component.explain = { error: { message: 'Error retrieving content' } };
@ -173,24 +188,19 @@ export class CommitDetailsApp extends App> {
const files = {
...this.state.preferences?.files,
layout: layout ?? 'auto',
compact: this.state.preferences?.files?.compact ?? true,
threshold: this.state.preferences?.files?.threshold ?? 5,
icon: this.state.preferences?.files?.icon ?? 'type',
};
this.state = { ...this.state, preferences: { ...this.state.preferences, files: files } };
this.attachState();
this.sendCommand(PreferencesCommandType, { files: files });
this.sendCommand(UpdatePreferencesCommandType, { files: files });
}
private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail) {
this.state.preferences = {
...this.state.preferences,
autolinksExpanded: e.expanded,
};
this.state = { ...this.state, preferences: { ...this.state.preferences, autolinksExpanded: e.expanded } };
this.attachState();
this.sendCommand(PreferencesCommandType, { autolinksExpanded: e.expanded });
this.sendCommand(UpdatePreferencesCommandType, { autolinksExpanded: e.expanded });
}
private onNavigate(direction: 'back' | 'forward', e: Event) {
@ -208,12 +218,19 @@ export class CommitDetailsApp extends App> {
this.sendCommand(AutolinkSettingsCommandType, undefined);
}
private onPickCommit(_e: MouseEvent) {
this.sendCommand(PickCommitCommandType, undefined);
}
private onSearchCommit(_e: MouseEvent) {
this.sendCommand(SearchCommitCommandType, undefined);
}
private onPickCommit(_e: MouseEvent) {
this.sendCommand(PickCommitCommandType, undefined);
private onSwitchMode(_e: MouseEvent, mode: Mode) {
this.state = { ...this.state, mode: mode };
this.attachState();
this.sendCommand(SwitchModeCommandType, { mode: mode, repoPath: this.state.commit?.repoPath });
}
private onOpenFileOnRemote(e: FileChangeListItemDetail) {
@ -236,9 +253,17 @@ export class CommitDetailsApp extends App> {
this.sendCommand(FileActionsCommandType, e);
}
onStageFile(e: FileChangeListItemDetail): void {
this.sendCommand(StageFileCommandType, e);
}
onUnstageFile(e: FileChangeListItemDetail): void {
this.sendCommand(UnstageFileCommandType, e);
}
private onCommitActions(e: MouseEvent) {
e.preventDefault();
if (this.state.selected === undefined) {
if (this.state.commit === undefined) {
e.stopPropagation();
return;
}

+ 59
- 674
src/webviews/apps/commitDetails/components/commit-details-app.ts Visa fil

@ -1,21 +1,16 @@
import type { TemplateResult } from 'lit';
import { html, LitElement, nothing } from 'lit';
import { Badge, defineGkElement } from '@gitkraken/shared-web-components';
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { when } from 'lit/directives/when.js';
import type { Autolink } from '../../../../annotations/autolinks';
import type { IssueOrPullRequest } from '../../../../git/models/issue';
import type { PullRequestShape } from '../../../../git/models/pullRequest';
import type { HierarchicalItem } from '../../../../system/array';
import { makeHierarchical } from '../../../../system/array';
import type { Serialized } from '../../../../system/serialize';
import { pluralize } from '../../../../system/string';
import type { State } from '../../../commitDetails/protocol';
import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol';
import '../../shared/components/button';
import './gl-commit-details';
import './gl-wip-details';
import { uncommittedSha } from '../commitDetails';
type Files = NonNullable<NonNullable<State['selected']>['files']>;
type File = Files[0];
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
@ -27,22 +22,17 @@ export class GlCommitDetailsApp extends LitElement {
@property({ type: Object })
state?: Serialized<State>;
@state()
explainBusy = false;
@property({ type: Object })
explain?: ExplainState;
@state()
get isUncommitted() {
return this.state?.selected?.sha === uncommittedSha;
return this.state?.commit?.sha === uncommittedSha;
}
@state()
get isStash() {
return this.state?.selected?.stashNumber != null;
}
get shortSha() {
return this.state?.selected?.shortSha ?? '';
return this.state?.commit?.stashNumber != null;
}
get navigation() {
@ -72,628 +62,65 @@ export class GlCommitDetailsApp extends LitElement {
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>
constructor() {
super();
<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>
defineGkElement(Badge);
}
<p>Alternatively, search for or choose a commit</p>
override render() {
const wip = this.state?.wip;
<p class="button-container">
<span class="button-group">
<button class="button button--full" type="button" data-action="pick-commit">
Choose Commit...
return html`
<div class="commit-detail-panel scrollable">
<main id="main" tabindex="-1">
<nav class="details-tab">
<button
class="details-tab__item ${this.state?.mode === 'commit' ? ' is-active' : ''}"
data-action="details"
>
${this.isStash ? 'Stash' : 'Commit'}
</button>
<button
class="button"
type="button"
data-action="search-commit"
aria-label="Search for Commit"
title="Search for Commit"
class="details-tab__item ${this.state?.mode === 'wip' ? ' is-active' : ''}"
data-action="wip"
title="${ifDefined(
this.state?.mode === 'wip' && wip?.changes?.files.length
? `${pluralize('change', wip.changes.files.length)} on ${
wip.repositoryCount > 1
? `${wip.changes.repository.name}:${wip.changes.branchName}`
: wip.changes.branchName
}`
: undefined,
)}"
>
<code-icon icon="search"></code-icon>
Working
Changes${ifDefined(
this.state?.mode === 'wip' && wip?.changes?.files.length
? html` &nbsp;<gk-badge variant="filled">${wip.changes.files.length}</gk-badge>`
: undefined,
)}
</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">
</nav>
${when(
index === -1,
this.state?.mode === 'commit',
() =>
html`<p class="message-block__text scrollable" data-region="message">
<strong>${unsafeHTML(message)}</strong>
</p>`,
html`<gl-commit-details
.state=${this.state}
.files=${this.state?.commit?.files}
.explain=${this.explain}
.preferences=${this.state?.preferences}
.isUncommitted=${this.isUncommitted}
></gl-commit-details>`,
() =>
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>`,
html`<gl-wip-details
.wip=${wip}
.files=${wip?.changes?.files}
.preferences=${this.state?.preferences}
.isUncommitted=${true}
.emptyText=${'No working changes'}
></gl-wip-details>`,
)}
</div>
</div>
`;
}
private renderAutoLinks() {
if (this.isUncommitted) {
return undefined;
}
const deduped = new Map<
string,
| { type: 'autolink'; value: Serialized<Autolink> }
| { type: 'issue'; value: Serialized<IssueOrPullRequest> }
| { type: 'pr'; value: Serialized<PullRequestShape> }
>();
if (this.state?.selected?.autolinks != null) {
for (const autolink of this.state.selected.autolinks) {
deduped.set(autolink.id, { type: 'autolink', value: autolink });
}
}
if (this.state?.autolinkedIssues != null) {
for (const issue of this.state.autolinkedIssues) {
deduped.set(issue.id, { type: 'issue', value: issue });
}
}
if (this.state?.pullRequest != null) {
deduped.set(this.state.pullRequest.id, { type: 'pr', value: this.state.pullRequest });
}
const autolinks: Serialized<Autolink>[] = [];
const issues: Serialized<IssueOrPullRequest>[] = [];
const prs: Serialized<PullRequestShape>[] = [];
for (const item of deduped.values()) {
switch (item.type) {
case 'autolink':
autolinks.push(item.value);
break;
case 'issue':
issues.push(item.value);
break;
case 'pr':
prs.push(item.value);
break;
}
}
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 || deduped.size ? `${deduped.size} 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 (deduped.size === 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">
${autolinks.length
? html`
<section
class="auto-link"
aria-label="Custom Autolinks"
data-region="custom-autolinks"
>
${autolinks.map(autolink => {
let name = autolink.description ?? autolink.title;
if (name === undefined) {
name = `Custom Autolink ${autolink.prefix}${autolink.id}`;
}
return html`
<issue-pull-request
type="autolink"
name="${name}"
url="${autolink.url}"
key="${autolink.prefix}${autolink.id}"
status=""
></issue-pull-request>
`;
})}
</section>
`
: undefined}
${prs.length
? html`
<section
class="pull-request"
aria-label="Pull request"
data-region="pull-request"
>
${prs.map(
pr => html`
<issue-pull-request
type="pr"
name="${pr.title}"
url="${pr.url}"
key="#${pr.id}"
status="${pr.state}"
date=${pr.date}
dateFormat="${this.state!.dateFormat}"
></issue-pull-request>
</section>
`,
)}
</section>
`
: undefined}
${issues.length
? html`
<section class="issue" aria-label="Issue" data-region="issue">
${issues.map(
issue => html`
<issue-pull-request
type="issue"
name="${issue.title}"
url="${issue.url}"
key="${issue.id}"
status="${issue.state}"
date="${issue.closed ? issue.closedDate : issue.date}"
dateFormat="${this.state!.dateFormat}"
></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() {
const files = this.state!.selected!.files!;
let items;
let classes;
if (this.isUncommitted) {
items = [];
classes = `indentGuides-${this.state!.indentGuides}`;
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
for (const f of staged) {
items.push(this.renderFile(f, 2, true));
}
}
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
for (const f of unstaged) {
items.push(this.renderFile(f, 2, true));
}
}
} else {
items = files.map(f => this.renderFile(f));
}
return html`<list-container class=${classes ?? nothing}>${items}</list-container>`;
}
private renderFileTree() {
const files = this.state!.selected!.files!;
const compact = this.state!.preferences?.files?.compact ?? true;
let items;
if (this.isUncommitted) {
items = [];
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
items.push(...this.renderFileSubtree(staged, 1, compact));
}
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
items.push(...this.renderFileSubtree(unstaged, 1, compact));
}
} else {
items = this.renderFileSubtree(files, 0, compact);
}
return html`<list-container class="indentGuides-${this.state!.indentGuides}">${items}</list-container>`;
}
private renderFileSubtree(files: Files, rootLevel: number, compact: boolean) {
const tree = makeHierarchical(
files,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
compact,
);
const flatTree = flattenHeirarchy(tree);
return flatTree.map(({ level, item }) => {
if (item.name === '') return undefined;
if (item.value == null) {
return html`
<list-item level="${rootLevel + level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return this.renderFile(item.value, rootLevel + level, true);
});
}
private renderFile(file: File, level: number = 1, tree: boolean = false): TemplateResult<1> {
return html`
<file-change-list-item
?tree=${tree}
level="${level}"
?stash=${this.isStash}
?uncommitted=${this.isUncommitted}
icon="${file.icon.dark}"
path="${file.path}"
repo="${file.repoPath}"
?staged=${file.staged}
status="${file.status}"
></file-change-list-item>
`;
}
private renderChangedFiles() {
const layout = this.state?.preferences?.files?.layout ?? 'auto';
let value = 'tree';
let icon = 'list-tree';
let label = 'View as Tree';
let isTree = false;
if (this.state?.selected?.files != null) {
if (layout === 'auto') {
isTree = this.state.selected.files.length > (this.state.preferences?.files?.threshold ?? 5);
} else {
isTree = layout === 'tree';
}
switch (layout) {
case 'auto':
value = 'list';
icon = 'gl-list-auto';
label = 'View as List';
break;
case 'list':
value = 'tree';
icon = 'list-flat';
label = 'View as Tree';
break;
case 'tree':
value = 'auto';
icon = 'list-tree';
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-action="files-layout"
data-files-layout="${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>
`;
@ -702,46 +129,4 @@ export class GlCommitDetailsApp extends LitElement {
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;
}

+ 523
- 0
src/webviews/apps/commitDetails/components/gl-commit-details.ts Visa fil

@ -0,0 +1,523 @@
import { html, 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 type { Autolink } from '../../../../annotations/autolinks';
import type { IssueOrPullRequest } from '../../../../git/models/issue';
import type { PullRequestShape } from '../../../../git/models/pullRequest';
import type { Serialized } from '../../../../system/serialize';
import type { State } from '../../../commitDetails/protocol';
import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol';
import { uncommittedSha } from '../commitDetails';
import { GlDetailsBase } from './gl-details-base';
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
summary?: string;
}
@customElement('gl-commit-details')
export class GlCommitDetails extends GlDetailsBase {
@property({ type: Object })
state?: Serialized<State>;
@state()
get isStash() {
return this.state?.commit?.stashNumber != null;
}
@state()
get shortSha() {
return this.state?.commit?.shortSha ?? '';
}
@state()
explainBusy = false;
@property({ type: Object })
explain?: ExplainState;
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, show your work-in-progress, or search for or choose a commit</p>
<p class="button-container">
<button class="button button--full" type="button" data-action="wip">Show Working Changes</button>
</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?.commit == null) return undefined;
const message = this.state.commit.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 deduped = new Map<
string,
| { type: 'autolink'; value: Serialized<Autolink> }
| { type: 'issue'; value: Serialized<IssueOrPullRequest> }
| { type: 'pr'; value: Serialized<PullRequestShape> }
>();
if (this.state?.commit?.autolinks != null) {
for (const autolink of this.state.commit.autolinks) {
deduped.set(autolink.id, { type: 'autolink', value: autolink });
}
}
if (this.state?.autolinkedIssues != null) {
for (const issue of this.state.autolinkedIssues) {
deduped.set(issue.id, { type: 'issue', value: issue });
}
}
if (this.state?.pullRequest != null) {
deduped.set(this.state.pullRequest.id, { type: 'pr', value: this.state.pullRequest });
}
const autolinks: Serialized<Autolink>[] = [];
const issues: Serialized<IssueOrPullRequest>[] = [];
const prs: Serialized<PullRequestShape>[] = [];
for (const item of deduped.values()) {
switch (item.type) {
case 'autolink':
autolinks.push(item.value);
break;
case 'issue':
issues.push(item.value);
break;
case 'pr':
prs.push(item.value);
break;
}
}
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 || deduped.size ? `${deduped.size} 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 (deduped.size === 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">
${autolinks.length
? html`
<section
class="auto-link"
aria-label="Custom Autolinks"
data-region="custom-autolinks"
>
${autolinks.map(autolink => {
let name = autolink.description ?? autolink.title;
if (name === undefined) {
name = `Custom Autolink ${autolink.prefix}${autolink.id}`;
}
return html`
<issue-pull-request
type="autolink"
name="${name}"
url="${autolink.url}"
key="${autolink.prefix}${autolink.id}"
status=""
></issue-pull-request>
`;
})}
</section>
`
: undefined}
${prs.length
? html`
<section
class="pull-request"
aria-label="Pull request"
data-region="pull-request"
>
${prs.map(
pr => html`
<issue-pull-request
type="pr"
name="${pr.title}"
url="${pr.url}"
key="#${pr.id}"
status="${pr.state}"
date=${pr.date}
dateFormat="${this.state!.preferences.dateFormat}"
></issue-pull-request>
</section>
`,
)}
</section>
`
: undefined}
${issues.length
? html`
<section class="issue" aria-label="Issue" data-region="issue">
${issues.map(
issue => html`
<issue-pull-request
type="issue"
name="${issue.title}"
url="${issue.url}"
key="${issue.id}"
status="${issue.state}"
date="${issue.closed ? issue.closedDate : issue.date}"
dateFormat="${this.state!.preferences.dateFormat}"
></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>
`;
}
override render() {
if (this.state?.commit == null) {
return this.renderEmptyContent();
}
const details = this.state.commit;
const pinLabel = this.state.pinned
? 'Unpin this Commit\nRestores Automatic Following'
: 'Pin this Commit\nSuspends Automatic Following';
return html`
<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(
details != null && !this.isStash,
() => html`
<ul class="top-details__authors" aria-label="Authors">
<li class="top-details__author" data-region="author">
<commit-identity
name="${details.author.name}"
email="${details.author.email}"
date=${details.author.date}
dateFormat="${this.preferences?.dateFormat}"
avatarUrl="${details.author.avatar ?? ''}"
showAvatar="${this.preferences?.avatars ?? true}"
actionLabel="${details.sha === uncommittedSha ? 'modified' : 'committed'}"
></commit-identity>
</li>
</ul>
`,
)}
</div>
</div>
${this.renderCommitMessage()} ${this.renderAutoLinks()}
${this.renderChangedFiles(this.isStash ? 'stash' : 'commit', this.renderCommitStats(details?.stats))}
${this.renderExplainAi()}
`;
}
onExplainChanges(e: MouseEvent | KeyboardEvent) {
if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) {
e.preventDefault();
e.stopPropagation();
return;
}
this.explainBusy = true;
}
private renderCommitStats(stats?: NonNullable<NonNullable<typeof this.state>['commit']>['stats']) {
if (stats?.changedFiles == null) return undefined;
if (typeof stats.changedFiles === 'number') {
return html`<commit-stats added="?" modified="${stats.changedFiles}" removed="?"></commit-stats>`;
}
const { added, deleted, changed } = stats.changedFiles;
return html`<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>`;
}
}

+ 235
- 0
src/webviews/apps/commitDetails/components/gl-details-base.ts Visa fil

@ -0,0 +1,235 @@
import type { TemplateResult } from 'lit';
import { html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { when } from 'lit/directives/when.js';
import type { HierarchicalItem } from '../../../../system/array';
import { makeHierarchical } from '../../../../system/array';
import type { Preferences, State } from '../../../commitDetails/protocol';
type Files = Mutable<NonNullable<NonNullable<State['commit']>['files']>>;
type File = Files[0];
type Mode = 'commit' | 'stash' | 'wip';
export class GlDetailsBase extends LitElement {
@property({ type: Array })
files?: Files;
@property({ type: Boolean })
isUncommitted = false;
@property({ type: Object })
preferences?: Preferences;
@property({ attribute: 'empty-text' })
emptyText? = 'No Files';
private renderFileList(mode: Mode, files: Files) {
let items;
let classes;
if (this.isUncommitted) {
items = [];
classes = `indentGuides-${this.preferences?.indentGuides}`;
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
for (const f of staged) {
items.push(this.renderFile(mode, f, 2, true));
}
}
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
for (const f of unstaged) {
items.push(this.renderFile(mode, f, 2, true));
}
}
} else {
items = files.map(f => this.renderFile(mode, f));
}
return html`<list-container class=${ifDefined(classes)}>${items}</list-container>`;
}
private renderFileTree(mode: Mode, files: Files) {
const compact = this.preferences?.files?.compact ?? true;
let items;
if (this.isUncommitted) {
items = [];
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
items.push(...this.renderFileSubtree(mode, staged, 1, compact));
}
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
items.push(...this.renderFileSubtree(mode, unstaged, 1, compact));
}
} else {
items = this.renderFileSubtree(mode, files, 0, compact);
}
return html`<list-container class="indentGuides-${this.preferences?.indentGuides}">${items}</list-container>`;
}
private renderFileSubtree(mode: Mode, files: Files, rootLevel: number, compact: boolean) {
const tree = makeHierarchical(
files,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
compact,
);
const flatTree = flattenHeirarchy(tree);
return flatTree.map(({ level, item }) => {
if (item.name === '') return undefined;
if (item.value == null) {
return html`
<list-item level="${rootLevel + level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return this.renderFile(mode, item.value, rootLevel + level, true);
});
}
private renderFile(mode: Mode, file: File, level: number = 1, tree: boolean = false): TemplateResult<1> {
return html`
<file-change-list-item
?tree=${tree}
level="${level}"
?stash=${mode === 'stash'}
?uncommitted=${this.isUncommitted}
?readonly=${this.isUncommitted && mode !== 'wip'}
path="${file.path}"
repo="${file.repoPath}"
?staged=${file.staged}
status="${file.status}"
></file-change-list-item>
`;
}
protected renderChangedFiles(mode: Mode, subtitle?: TemplateResult<1>) {
const layout = this.preferences?.files?.layout ?? 'auto';
let value = 'tree';
let icon = 'list-tree';
let label = 'View as Tree';
let isTree = false;
if (this.preferences != null && this.files != null) {
if (layout === 'auto') {
isTree = this.files.length > (this.preferences.files?.threshold ?? 5);
} else {
isTree = layout === 'tree';
}
switch (layout) {
case 'auto':
value = 'list';
icon = 'gl-list-auto';
label = 'View as List';
break;
case 'list':
value = 'tree';
icon = 'list-flat';
label = 'View as Tree';
break;
case 'tree':
value = 'auto';
icon = 'list-tree';
label = 'View as Auto';
break;
}
}
return html`
<webview-pane collapsable expanded>
<span slot="title">Files changed </span>
<span slot="subtitle" data-region="stats">${subtitle}</span>
<action-nav slot="actions">
<action-item
data-action="files-layout"
data-files-layout="${value}"
label="${label}"
icon="${icon}"
></action-item>
</action-nav>
<div class="change-list" data-region="files">
${when(
this.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>
`,
() =>
when(
this.files!.length > 0,
() =>
isTree
? this.renderFileTree(mode, this.files!)
: this.renderFileList(mode, this.files!),
() => html`<div class="section"><p>${this.emptyText}</p></div>`,
),
)}
</div>
</webview-pane>
`;
}
protected override createRenderRoot() {
return this;
}
}
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;
}

+ 67
- 0
src/webviews/apps/commitDetails/components/gl-wip-details.ts Visa fil

@ -0,0 +1,67 @@
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { pluralize } from '../../../../system/string';
import type { Wip } from '../../../commitDetails/protocol';
import { GlDetailsBase } from './gl-details-base';
@customElement('gl-wip-details')
export class GlWipDetails extends GlDetailsBase {
@property({ type: Object })
wip?: Wip;
override render() {
return html`
<div class="top-details">
<div class="top-details__top-menu">
<div class="top-details__actionbar">
<div class="top-details__actionbar-group">
${when(
this.wip?.changes == null || this.files == null,
() => 'Loading...',
() =>
html`<span
>${pluralize('change', this.files!.length)} on
<span
class="top-details__actionbar--highlight"
title="${this.wip!.repositoryCount > 1
? `${this.wip!.changes!.repository.name}:${
this.wip!.changes!.branchName
}`
: this.wip!.changes!.branchName}"
>${this.wip!.repositoryCount > 1
? `${this.wip!.changes!.repository.name}:${
this.wip!.changes!.branchName
}`
: this.wip!.changes!.branchName}</span
></span
>`,
)}
</div>
<div class="top-details__actionbar-group">
<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>
</div>
</div>
</div>
</div>
${this.renderChangedFiles('wip')}
`;
}
}

+ 5
- 4
src/webviews/apps/shared/components/commit/commit-identity.ts Visa fil

@ -1,5 +1,6 @@
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { dateConverter } from '../converters/date-converter';
import '../code-icon';
import '../formatted-date';
@ -48,8 +49,8 @@ export class CommitIdentity extends LitElement {
@property()
email = '';
@property()
date = '';
@property({ converter: dateConverter(), reflect: true })
date: Date | undefined;
@property()
avatarUrl = 'https://www.gravatar.com/avatar/?s=64&d=robohash';
@ -60,7 +61,7 @@ export class CommitIdentity extends LitElement {
@property()
dateFormat = 'MMMM Do, YYYY h:mma';
@property()
@property({ type: Boolean })
committer = false;
@property()
@ -76,7 +77,7 @@ export class CommitIdentity extends LitElement {
<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>
<formatted-date date=${this.date?.getTime()} format=${this.dateFormat}> </formatted-date>
</span>
`;
}

+ 52
- 31
src/webviews/apps/shared/components/list/file-change-list-item.ts Visa fil

@ -3,6 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import type { TextDocumentShowOptions } from 'vscode';
import type { ListItem, ListItemSelectedEvent } from './list-item';
import '../code-icon';
import '../status/git-status';
// Can only import types from 'vscode'
const BesideViewColumn = -2; /*ViewColumn.Beside*/
@ -17,27 +18,6 @@ export interface FileChangeListItemDetail {
// TODO: "change-list__action" should be a separate component
// 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',
};
@customElement('file-change-list-item')
export class FileChangeListItem extends LitElement {
static override styles = css`
@ -81,6 +61,9 @@ export class FileChangeListItem extends LitElement {
@property({ type: Number })
level = 1;
@property({ type: Boolean, reflect: true }) checkable = false;
@property({ type: Boolean, reflect: true }) checked = false;
@property({ type: Boolean })
active = false;
@ -90,6 +73,9 @@ export class FileChangeListItem extends LitElement {
@property({ type: Boolean })
uncommitted = false;
@property({ type: Boolean })
readonly = false;
@property({ type: String })
icon = '';
@ -137,11 +123,6 @@ export class FileChangeListItem extends LitElement {
return !this.tree && this.pathIndex > -1 ? this.path.substring(0, this.pathIndex) : '';
}
@state()
get statusName() {
return this.status !== '' ? statusTextMap[this.status] : '';
}
override render() {
return html`
<list-item
@ -150,9 +131,11 @@ export class FileChangeListItem extends LitElement {
?active=${this.active}
?expanded=${this.expanded}
?parentexpanded=${this.parentexpanded}
?checkable=${this.checkable}
?checked=${this.checked}
@selected=${this.onComparePrevious}
>
<img slot="icon" .src=${this.icon} .title=${this.statusName} .alt=${this.statusName} />
<gl-git-status slot="icon" .status=${this.status}></gl-git-status>
${this.fileName} ${this.tree ? nothing : html`<span slot="description">${this.filePath}</span>`}
<span slot="actions">
<a
@ -164,9 +147,32 @@ export class FileChangeListItem extends LitElement {
>
<code-icon icon="go-to-file"></code-icon>
</a>
${this.uncommitted
? nothing
: html`
${this.uncommitted && !this.readonly
? this.staged
? html`
<a
class="change-list__action"
@click=${this.onUnstageFile}
href="#"
title="Unstage Changes"
aria-label="Unstage Changes"
>
<code-icon icon="remove"></code-icon>
</a>
`
: html`
<a
class="change-list__action"
@click=${this.onStageFile}
href="#"
title="Stage Changes"
aria-label="Stage Changes"
>
<code-icon icon="plus"></code-icon>
</a>
`
: !this.uncommitted
? html`
<a
class="change-list__action"
@click=${this.onCompareWorking}
@ -198,7 +204,8 @@ export class FileChangeListItem extends LitElement {
<code-icon icon="ellipsis"></code-icon>
</a>
`}
`}
`
: nothing}
</span>
</list-item>
`;
@ -239,6 +246,20 @@ export class FileChangeListItem extends LitElement {
this.dispatchEvent(event);
}
onStageFile(_e: MouseEvent) {
const event = new CustomEvent('file-stage', {
detail: this.getEventDetail(),
});
this.dispatchEvent(event);
}
onUnstageFile(_e: MouseEvent) {
const event = new CustomEvent('file-unstage', {
detail: this.getEventDetail(),
});
this.dispatchEvent(event);
}
private getEventDetail(showOptions?: TextDocumentShowOptions): FileChangeListItemDetail {
return {
path: this.path,

+ 0
- 1
src/webviews/apps/shared/components/list/list-container.ts Visa fil

@ -63,7 +63,6 @@ export class ListContainer extends LitElement {
if (!e.target || !e.detail.branch) return;
function getLevel(el: ListItem) {
// return el.level ?? 0;
return parseInt(el.getAttribute('level') ?? '0', 10);
}

+ 2
- 0
src/webviews/apps/shared/components/list/list-item.ts Visa fil

@ -77,6 +77,7 @@ export class ListItem extends LitElement {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0;
@ -130,6 +131,7 @@ export class ListItem extends LitElement {
}
.text {
line-height: 1.6rem;
overflow: hidden;
white-space: nowrap;
text-align: left;

+ 237
- 0
src/webviews/apps/shared/components/status/git-status.ts Visa fil

@ -0,0 +1,237 @@
import type { PropertyValueMap } from 'lit';
import { css, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { GitFileStatus } from '../../../../../git/models/file';
// 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',
};
@customElement('gl-git-status')
export class GlGitStatus extends LitElement {
static override styles = [
css`
:host-context(.vscode-high-contrast),
:host-context(.vscode-dark) {
--gl-git-status-ignored: #969696;
--gl-git-status-untracked: #6c6c6c;
--gl-git-status-added: #388a34;
--gl-git-status-deleted: #9e121d;
--gl-git-status-modified: #1b80b2;
--gl-git-status-renamed: #c63;
--gl-git-status-copied: #692c77;
--gl-git-status-conflict: #7f4e7e;
--gl-git-status-unknown: #6c6c6c;
}
:host-context(.vscode-high-contrast-light),
:host-context(.vscode-light) {
--gl-git-status-ignored: #969696;
--gl-git-status-untracked: #6c6c6c;
--gl-git-status-added: #388a34;
--gl-git-status-deleted: #9e121d;
--gl-git-status-modified: #1b80b2;
--gl-git-status-renamed: #c63;
--gl-git-status-copied: #692c77;
--gl-git-status-conflict: #7f4e7e;
--gl-git-status-unknown: #6c6c6c;
}
:host {
display: inline-block;
width: 16px;
aspect-ratio: 1 / 1;
}
svg {
display: inline-block;
vertical-align: text-bottom;
}
`,
];
@property()
status?: GitFileStatus | 'T';
@state()
get statusName() {
if (!this.status) return '';
return statusTextMap[this.status];
}
override updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('status')) {
if (this.statusName) {
this.setAttribute('title', this.statusName);
} else {
this.removeAttribute('title');
}
}
}
renderIgnored() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#969696"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zM10 4l-6.01 6.01 1.06 1.061 6.01-6.01L10 4z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderUntracked() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#6C6C6C"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm-3.942-3.942l7.5-7.5.884.884-.664.664c.95.655 1.65 1.524 2.222 2.394-1.15 1.75-2.824 3.5-6 3.5-.696 0-1.32-.084-1.882-.234l-1.176 1.176-.884-.884zm5.188-3.42l1.629-1.629c.634.393 1.147.913 1.594 1.491C10.99 8.767 9.692 9.75 7.5 9.75c-.287 0-.56-.017-.817-.05l.455-.454a1.5 1.5 0 0 0 1.608-1.608zM7.362 6.254L5.754 7.862a1.5 1.5 0 0 1 1.608-1.608zm.955-.955A6.595 6.595 0 0 0 7.5 5.25c-2.192 0-3.49.982-4.469 2.25.447.578.96 1.098 1.594 1.491l-.903.903C2.772 9.239 2.072 8.369 1.5 7.5 2.65 5.75 4.324 4 7.5 4c.696 0 1.32.084 1.882.234L8.317 5.299z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderAdded() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#388A34"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm.75-6.75h3v-1.5h-3v-3h-1.5v3h-3v1.5h3v3h1.5v-3z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderDeleted() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#9E121D"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm3.75-6.75v-1.5h-7.5v1.5h7.5z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderModified() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#1B80B2"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm3.75-9.5V7h-3v2.5h-1.5V7h-3V5.5h3v-3h1.5v3h3zm0 5V12h-7.5v-1.5h7.5z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderRenamed() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#C63"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zM9.25 4.5v1.25h1.25l1 1v2.5l-1 1H9.25v1.25H10v1.25H7V11.5h.75v-1.25H4l-1-1v-2.5l1-1h3.75V4.5H7V3.25h3V4.5h-.75zm-5 2.5h3.5v2h-3.5V7zm5 0v2h1V7h-1z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderCopied() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#692C77"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zM6.964 3.75L5.893 4.813v.53h1.071v-.53h3.215v4.25h-.536v1.062h.536l1.071-1.063v-4.25L10.179 3.75H6.964zM3.75 6.938l1.071-1.063h3.215l1.071 1.063v4.25L8.036 12.25H4.82L3.75 11.187v-4.25zm1.071 0v4.25h3.215v-4.25H4.82z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderConflict() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#7F4E7E"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zm-4.03-4.53L6.44 7.5 3.47 4.53l1.06-1.06L7.5 6.44l2.97-2.97 1.06 1.06L8.56 7.5l2.97 2.97-1.06 1.06L7.5 8.56l-2.97 2.97-1.06-1.06z"
clip-rule="evenodd"
/>
</svg>
`;
}
renderUnknown() {
return html`
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#6C6C6C"
fill-rule="evenodd"
d="M7.5 15a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15zM9.19 2.822c-.439-.215-.97-.322-1.596-.322-1.25 0-2.282.478-3.094 1.435l1.05.798c.275-.331.579-.574.91-.728.331-.154.66-.231.987-.231.415 0 .76.093 1.036.28.275.182.413.448.413.798 0 .275-.082.509-.245.7-.159.187-.36.364-.602.532a9.506 9.506 0 0 0-.728.56 2.66 2.66 0 0 0-.602.763c-.159.299-.238.679-.238 1.141v.483h1.498v-.413c0-.364.086-.663.259-.896a2.76 2.76 0 0 1 .637-.616c.252-.177.504-.362.756-.553.257-.196.471-.436.644-.721.173-.285.259-.651.259-1.099 0-.387-.114-.749-.343-1.085-.229-.34-.562-.616-1.001-.826zm-1.169 7.917a1.024 1.024 0 0 0-.763-.315c-.294 0-.544.105-.749.315-.2.205-.301.453-.301.742 0 .294.1.546.301.756.205.205.455.308.749.308.303 0 .558-.103.763-.308.205-.21.308-.462.308-.756 0-.29-.103-.537-.308-.742z"
clip-rule="evenodd"
/>
</svg>
`;
}
override render() {
switch (this.status) {
case '!':
return this.renderIgnored();
case '?':
return this.renderUntracked();
case 'A':
return this.renderAdded();
case 'D':
return this.renderDeleted();
case 'M':
case 'T':
case 'U':
return this.renderModified();
case 'R':
return this.renderRenamed();
case 'C':
return this.renderCopied();
case 'AA':
case 'AU':
case 'UA':
case 'DD':
case 'DU':
case 'UD':
case 'UU':
return this.renderConflict();
}
return this.renderUnknown();
}
}

+ 348
- 170
src/webviews/commitDetails/commitDetailsWebview.ts Visa fil

@ -18,8 +18,8 @@ import {
import { CommitFormatter } from '../../git/formatters/commitFormatter';
import type { GitCommit } from '../../git/models/commit';
import { isCommit } from '../../git/models/commit';
import type { GitFileChange } from '../../git/models/file';
import { getGitFileStatusIcon } from '../../git/models/file';
import { uncommitted } from '../../git/models/constants';
import type { GitFileChange, GitFileChangeShape } from '../../git/models/file';
import type { IssueOrPullRequest } from '../../git/models/issue';
import { serializeIssueOrPullRequest } from '../../git/models/issue';
import type { PullRequest } from '../../git/models/pullRequest';
@ -27,12 +27,13 @@ import { serializePullRequest } from '../../git/models/pullRequest';
import type { GitRevisionReference } from '../../git/models/reference';
import { createReference, getReferenceFromRevision, shortenRevision } from '../../git/models/reference';
import type { GitRemote } from '../../git/models/remote';
import type { Repository } from '../../git/models/repository';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol';
import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation';
import { executeCommand, executeCoreCommand, registerCommand } from '../../system/command';
import { configuration } from '../../system/configuration';
import { getContext } from '../../system/context';
import type { DateTimeFormat } from '../../system/date';
import { debug } from '../../system/decorators/log';
import type { Deferrable } from '../../system/function';
import { debounce } from '../../system/function';
@ -49,13 +50,25 @@ import { onIpc } from '../protocol';
import type { WebviewController, WebviewProvider } from '../webviewController';
import { updatePendingContext } from '../webviewController';
import { isSerializedState } from '../webviewsController';
import type { CommitDetails, DidExplainCommitParams, FileActionParams, Preferences, State } from './protocol';
import type {
CommitDetails,
DidExplainParams,
FileActionParams,
Mode,
Preferences,
State,
SwitchModeParams,
UpdateablePreferences,
Wip,
WipChange,
} from './protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
DidExplainCommitCommandType,
ExplainCommitCommandType,
DidChangeWipStateNotificationType,
DidExplainCommandType,
ExplainCommandType,
FileActionsCommandType,
messageHeadlineSplitterToken,
NavigateCommitCommandType,
@ -65,30 +78,32 @@ import {
OpenFileOnRemoteCommandType,
PickCommitCommandType,
PinCommitCommandType,
PreferencesCommandType,
SearchCommitCommandType,
StageFileCommandType,
SwitchModeCommandType,
UnstageFileCommandType,
UpdatePreferencesCommandType,
} from './protocol';
interface Context {
pinned: boolean;
commit: GitCommit | undefined;
preferences: Preferences | undefined;
richStateLoaded: boolean;
formattedMessage: string | undefined;
autolinkedIssues: IssueOrPullRequest[] | undefined;
pullRequest: PullRequest | undefined;
type RepositorySubscription = { repo: Repository; subscription: Disposable };
// commits: GitCommit[] | undefined;
dateFormat: DateTimeFormat | string;
// indent: number;
indentGuides: 'none' | 'onHover' | 'always';
interface Context {
mode: Mode;
navigationStack: {
count: number;
position: number;
hint?: string;
};
pinned: boolean;
preferences: Preferences;
visible: boolean;
commit: GitCommit | undefined;
richStateLoaded: boolean;
formattedMessage: string | undefined;
autolinkedIssues: IssueOrPullRequest[] | undefined;
pullRequest: PullRequest | undefined;
wip: Wip | undefined;
}
export class CommitDetailsWebviewProvider implements WebviewProvider<State, Serialized<State>> {
@ -105,30 +120,34 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
constructor(
private readonly container: Container,
private readonly host: WebviewController<State, Serialized<State>>,
private readonly options: { mode: 'default' | 'graph' },
private readonly options: { attachedTo: 'default' | 'graph' },
) {
this._context = {
mode: 'commit',
navigationStack: {
count: 0,
position: 0,
},
pinned: false,
commit: undefined,
preferences: {
autolinksExpanded: this.container.storage.getWorkspace('views:commitDetails:autolinksExpanded'),
autolinksExpanded: this.container.storage.getWorkspace('views:commitDetails:autolinksExpanded') ?? true,
avatars: configuration.get('views.commitDetails.avatars'),
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
files: configuration.get('views.commitDetails.files'),
// indent: configuration.getAny('workbench.tree.indent') ?? 8,
indentGuides:
configuration.getAny<CoreConfiguration, Preferences['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
},
visible: false,
commit: undefined,
richStateLoaded: false,
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
// indent: configuration.getAny('workbench.tree.indent') ?? 8,
indentGuides:
configuration.getAny<CoreConfiguration, Context['indentGuides']>('workbench.tree.renderIndentGuides') ??
'onHover',
navigationStack: {
count: 0,
position: 0,
},
visible: false,
wip: undefined,
};
this._disposable = configuration.onDidChangeAny(this.onAnyConfigurationChanged, this);
@ -136,6 +155,10 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
dispose() {
this._disposable.dispose();
this._commitTrackerDisposable?.dispose();
this._lineTrackerDisposable?.dispose();
this._repositorySubscription?.subscription.dispose();
this._wipSubscription?.subscription.dispose();
}
onReloaded(): void {
@ -151,7 +174,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
const [arg] = args;
if (isSerializedState<Serialized<State>>(arg)) {
const { selected } = arg.state;
const { commit: selected } = arg.state;
if (selected?.repoPath != null && selected?.sha != null) {
if (selected.stashNumber != null) {
data = {
@ -184,11 +207,16 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
({ commit, ...data } = data);
}
if (commit != null && this.mode === 'wip' && data?.interaction !== 'passive') {
this.setMode('commit');
}
if (commit == null) {
if (!this._pinned) {
commit = this.getBestCommitOrStash();
}
}
if (commit != null && !this._context.commit?.ref.startsWith(commit.ref)) {
await this.updateCommit(commit, { pinned: false });
}
@ -214,12 +242,20 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
private onCommitSelected(e: CommitSelectedEvent) {
if (
e.data == null ||
(this.options.mode === 'graph' && e.source !== 'gitlens.views.graph') ||
(this.options.mode === 'default' && e.source === 'gitlens.views.graph')
(this.options.attachedTo === 'graph' && e.source !== 'gitlens.views.graph') ||
(this.options.attachedTo === 'default' && e.source === 'gitlens.views.graph')
) {
return;
}
if (this.mode === 'wip') {
if (e.data.commit.repoPath !== this._context.wip?.changes?.repository.path) {
void this.updateWipState(this.container.git.getRepository(e.data.commit.repoPath));
}
return;
}
if (this._pinned && e.data.interaction === 'passive') {
this._commitStack.insert(getReferenceFromRevision(e.data.commit));
this.updateNavigation();
@ -247,56 +283,44 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
this._bootstraping = false;
if (this._pendingContext == null) return;
}
this.onRefresh();
this.updateState(true);
}
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
if (configuration.changed(e, 'defaultDateFormat')) {
this.updatePendingContext({ dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma' });
this.updateState();
} else {
this.onRefresh();
this.updateState(true);
}
}
if (configuration.changed(e, 'views.commitDetails')) {
if (
configuration.changed(e, 'views.commitDetails.files') ||
configuration.changed(e, 'views.commitDetails.avatars')
) {
this.updatePendingContext({
preferences: {
...this._context.preferences,
...this._pendingContext?.preferences,
avatars: configuration.get('views.commitDetails.avatars'),
files: configuration.get('views.commitDetails.files'),
},
});
}
if (
this._context.commit != null &&
(configuration.changed(e, 'views.commitDetails.autolinks') ||
configuration.changed(e, 'views.commitDetails.pullRequests'))
) {
void this.updateCommit(this._context.commit, { force: true });
}
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
if (
configuration.changed(e, [
'defaultDateFormat',
'views.commitDetails.files',
'views.commitDetails.avatars',
]) ||
configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.renderIndentGuides')
) {
this.updatePendingContext({
preferences: {
...this._context.preferences,
...this._pendingContext?.preferences,
avatars: configuration.get('views.commitDetails.avatars'),
dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
files: configuration.get('views.commitDetails.files'),
indentGuides:
configuration.getAny<CoreConfiguration, Preferences['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
},
});
this.updateState();
}
// if (configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.indent')) {
// this.updatePendingContext({ indent: configuration.getAny('workbench.tree.indent') ?? 8 });
// this.updateState();
// }
if (configuration.changedAny<CoreConfiguration>(e, 'workbench.tree.renderIndentGuides')) {
this.updatePendingContext({
indentGuides:
configuration.getAny<CoreConfiguration, Context['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
});
if (
this._context.commit != null &&
configuration.changed(e, ['views.commitDetails.autolinks', 'views.commitDetails.pullRequests'])
) {
void this.updateCommit(this._context.commit, { force: true });
this.updateState();
}
}
@ -315,7 +339,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
if (this._pinned) return;
if (this.options.mode !== 'graph') {
if (this.options.attachedTo !== 'graph') {
const { lineTracker } = this.container;
this._lineTrackerDisposable = lineTracker.subscribe(
this,
@ -325,7 +349,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
}
private get isLineTrackerSuspended() {
return this.options.mode !== 'graph' ? this._lineTrackerDisposable == null : false;
return this.options.attachedTo !== 'graph' ? this._lineTrackerDisposable == null : false;
}
private suspendLineTracker() {
@ -339,8 +363,15 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
onRefresh(_force?: boolean | undefined): void {
if (this._pinned) return;
const commit = this._pendingContext?.commit ?? this.getBestCommitOrStash();
void this.updateCommit(commit, { immediate: false });
if (this.mode === 'wip') {
const uri = this._context.wip?.changes?.repository.uri;
void this.updateWipState(
this.container.git.getBestRepositoryOrFirst(uri != null ? Uri.parse(uri) : undefined),
);
} else {
const commit = this._pendingContext?.commit ?? this.getBestCommitOrStash();
void this.updateCommit(commit, { immediate: false });
}
}
onMessageReceived(e: IpcMessage) {
@ -367,18 +398,31 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
case CommitActionsCommandType.method:
onIpc(CommitActionsCommandType, e, params => {
switch (params.action) {
case 'graph':
if (this._context.commit == null) return;
case 'graph': {
let ref: GitRevisionReference | undefined;
if (this._context.mode === 'wip') {
ref =
this._context.wip?.changes != null
? createReference(uncommitted, this._context.wip.changes.repository.path, {
refType: 'revision',
})
: undefined;
} else {
ref =
this._context.commit != null
? getReferenceFromRevision(this._context.commit)
: undefined;
}
if (ref == null) return;
void executeCommand<ShowInCommitGraphCommandArgs>(
this.options.mode === 'graph'
this.options.attachedTo === 'graph'
? Commands.ShowInCommitGraphView
: Commands.ShowInCommitGraph,
{
ref: getReferenceFromRevision(this._context.commit),
},
{ ref: ref },
);
break;
}
case 'more':
this.showCommitActions();
break;
@ -403,6 +447,9 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
case SearchCommitCommandType.method:
onIpc(SearchCommitCommandType, e, _params => this.showCommitSearch());
break;
case SwitchModeCommandType.method:
onIpc(SwitchModeCommandType, e, params => this.switchMode(params));
break;
case AutolinkSettingsCommandType.method:
onIpc(AutolinkSettingsCommandType, e, _params => this.showAutolinkSettings());
break;
@ -412,16 +459,56 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
case NavigateCommitCommandType.method:
onIpc(NavigateCommitCommandType, e, params => this.navigateStack(params.direction));
break;
case PreferencesCommandType.method:
onIpc(PreferencesCommandType, e, params => this.updatePreferences(params));
case UpdatePreferencesCommandType.method:
onIpc(UpdatePreferencesCommandType, e, params => this.updatePreferences(params));
break;
case ExplainCommandType.method:
onIpc(ExplainCommandType, e, () => this.explainCommit(e.completionId));
break;
case ExplainCommitCommandType.method:
onIpc(ExplainCommitCommandType, e, () => this.explainCommit(e.completionId));
case StageFileCommandType.method:
onIpc(StageFileCommandType, e, params => this.stageFile(params));
break;
case UnstageFileCommandType.method:
onIpc(UnstageFileCommandType, e, params => this.unstageFile(params));
break;
}
}
private onActiveEditorLinesChanged(e: LinesChangeEvent) {
if (e.pending || e.editor == null || e.suspended) return;
if (this.mode === 'wip') {
const repo = this.container.git.getBestRepositoryOrFirst(e.editor);
void this.updateWipState(repo);
return;
}
const line = e.selections?.[0]?.active;
const commit = line != null ? this.container.lineTracker.getState(line)?.commit : undefined;
void this.updateCommit(commit);
}
private _wipSubscription: RepositorySubscription | undefined;
private get mode(): Mode {
return this._pendingContext?.mode ?? this._context.mode;
}
private setMode(mode: Mode, repository?: Repository) {
this.updatePendingContext({ mode: mode });
if (mode === 'commit') {
this._wipSubscription?.subscription.dispose();
this._wipSubscription = undefined;
this.updateState(true);
} else {
void this.updateWipState(repository ?? this.container.git.getBestRepositoryOrFirst());
}
}
private async explainCommit(completionId?: string) {
let params: DidExplainCommitParams;
let params: DidExplainParams;
try {
const summary = await this.container.ai.explainCommit(this._context.commit!, {
progress: { location: { viewId: this.host.id } },
@ -431,7 +518,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
debugger;
params = { error: { message: ex.message } };
}
void this.host.notify(DidExplainCommitCommandType, params, completionId);
void this.host.notify(DidExplainCommandType, params, completionId);
}
private navigateStack(direction: 'back' | 'forward') {
@ -441,15 +528,6 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
void this.updateCommit(commit, { immediate: true, skipStack: true });
}
private onActiveEditorLinesChanged(e: LinesChangeEvent) {
if (e.pending || e.editor == null) return;
const line = e.selections?.[0]?.active;
const commit = line != null ? this.container.lineTracker.getState(line)?.commit : undefined;
void this.updateCommit(commit);
}
private _cancellationTokenSource: CancellationTokenSource | undefined = undefined;
@debug({ args: false })
@ -474,27 +552,56 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
}
}
// const commitChoices = await Promise.all(this.commits.map(async commit => summaryModel(commit)));
const state = serialize<State>({
mode: current.mode,
webviewId: this.host.id,
timestamp: Date.now(),
commit: details,
navigationStack: current.navigationStack,
pinned: current.pinned,
includeRichContent: current.richStateLoaded,
// commits: commitChoices,
preferences: current.preferences,
selected: details,
includeRichContent: current.richStateLoaded,
autolinkedIssues: current.autolinkedIssues?.map(serializeIssueOrPullRequest),
pullRequest: current.pullRequest != null ? serializePullRequest(current.pullRequest) : undefined,
dateFormat: current.dateFormat,
// indent: current.indent,
indentGuides: current.indentGuides,
navigationStack: current.navigationStack,
wip: current.wip,
});
return state;
}
@debug({ args: false })
private async updateWipState(repository: Repository | undefined): Promise<void> {
if (this._wipSubscription != null) {
const { repo, subscription } = this._wipSubscription;
if (repository?.path !== repo.path) {
subscription.dispose();
this._wipSubscription = undefined;
}
}
let wip: Wip | undefined = undefined;
if (repository != null) {
if (this._wipSubscription == null) {
this._wipSubscription = { repo: repository, subscription: this.subscribeToRepositoryWip(repository) };
}
const changes = await this.getWipChange(repository);
wip = { changes: changes, repositoryCount: this.container.git.openRepositoryCount };
if (this._pendingContext == null) {
const success = await this.host.notify(DidChangeWipStateNotificationType, { wip: wip });
if (success) {
this._context.wip = wip;
return;
}
}
}
this.updatePendingContext({ wip: wip });
this.updateState(true);
}
@debug({ args: false })
private async updateRichState(current: Context, cancellation: CancellationToken): Promise<void> {
const { commit } = current;
if (commit == null) return;
@ -545,7 +652,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
// };
}
private _commitDisposable: Disposable | undefined;
private _repositorySubscription: RepositorySubscription | undefined;
private async updateCommit(
commitish: GitCommit | GitRevisionReference | undefined,
@ -554,8 +661,6 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
// this.commits = [commit];
if (!options?.force && this._context.commit?.sha === commitish?.ref) return;
this._commitDisposable?.dispose();
let commit: GitCommit | undefined;
if (isCommit(commitish)) {
commit = commitish;
@ -568,17 +673,27 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
}
}
if (commit?.isUncommitted) {
const repository = await this.container.git.getOrOpenRepository(commit.repoPath);
if (repository != null) {
this._commitDisposable = Disposable.from(
repository.startWatchingFileSystem(),
repository.onDidChangeFileSystem(() => {
// this.updatePendingContext({ commit: undefined });
this.updatePendingContext({ commit: commit }, true);
this.updateState();
}),
);
let wip = this._pendingContext?.wip ?? this._context.wip;
if (this._repositorySubscription != null) {
const { repo, subscription } = this._repositorySubscription;
if (commit?.repoPath !== repo.path) {
subscription.dispose();
this._repositorySubscription = undefined;
wip = undefined;
}
}
if (this._repositorySubscription == null && commit != null) {
const repo = await this.container.git.getOrOpenRepository(commit.repoPath);
if (repo != null) {
this._repositorySubscription = { repo: repo, subscription: this.subscribeToRepositoryWip(repo) };
if (this.mode === 'wip') {
void this.updateWipState(repo);
} else {
wip = undefined;
}
}
}
@ -589,6 +704,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
formattedMessage: undefined,
autolinkedIssues: undefined,
pullRequest: undefined,
wip: wip,
},
options?.force,
);
@ -611,6 +727,56 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
this.updateState(options?.immediate ?? true);
}
private subscribeToRepositoryWip(repo: Repository) {
return Disposable.from(
repo.startWatchingFileSystem(),
repo.onDidChangeFileSystem(() => this.onWipChanged(repo)),
repo.onDidChange(e => {
if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) {
this.onWipChanged(repo);
}
}),
);
}
private onWipChanged(repository: Repository) {
void this.updateWipState(repository);
}
private async getWipChange(repository: Repository): Promise<WipChange | undefined> {
const status = await this.container.git.getStatusForRepo(repository.path);
if (status == null) return undefined;
const files: GitFileChangeShape[] = [];
for (const file of status.files) {
const change = {
repoPath: file.repoPath,
path: file.path,
status: file.status,
originalPath: file.originalPath,
staged: file.staged,
};
files.push(change);
if (file.staged && file.wip) {
files.push({
...change,
staged: false,
});
}
}
return {
repository: {
name: repository.name,
path: repository.path,
uri: repository.uri.toString(),
},
branchName: status.branch,
files: files,
};
}
private updatePinned(pinned: boolean, immediate?: boolean) {
if (pinned === this._context.pinned) return;
@ -621,11 +787,9 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
this.updateState(immediate);
}
private updatePreferences(preferences: Preferences) {
private updatePreferences(preferences: UpdateablePreferences) {
if (
this._context.preferences?.autolinksExpanded === preferences.autolinksExpanded &&
this._context.preferences?.avatars === preferences.avatars &&
this._context.preferences?.files === preferences.files &&
this._context.preferences?.files?.compact === preferences.files?.compact &&
this._context.preferences?.files?.icon === preferences.files?.icon &&
this._context.preferences?.files?.layout === preferences.files?.layout &&
@ -651,13 +815,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
changes.autolinksExpanded = preferences.autolinksExpanded;
}
if (preferences.avatars != null && this._context.preferences?.avatars !== preferences.avatars) {
void configuration.updateEffective('views.commitDetails.avatars', preferences.avatars);
changes.avatars = preferences.avatars;
}
if (preferences.files != null && this._context.preferences?.files !== preferences.files) {
if (preferences.files != null) {
if (this._context.preferences?.files?.compact !== preferences.files?.compact) {
void configuration.updateEffective('views.commitDetails.files.compact', preferences.files?.compact);
}
@ -744,21 +902,12 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
});
}
// private async updateRichState() {
// if (this.commit == null) return;
// const richState = await this.getRichState(this.commit);
// if (richState != null) {
// void this.notify(DidChangeRichStateNotificationType, richState);
// }
// }
private getBestCommitOrStash(): GitCommit | GitRevisionReference | undefined {
if (this._pinned) return undefined;
let commit;
if (this.options.mode !== 'graph' && window.activeTextEditor != null) {
if (this.options.attachedTo !== 'graph' && window.activeTextEditor != null) {
const { lineTracker } = this.container;
const line = lineTracker.selections?.[0].active;
if (line != null) {
@ -800,25 +949,9 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
author: { ...commit.author, avatar: avatarUri?.toString(true) },
// committer: { ...commit.committer, avatar: committerAvatar?.toString(true) },
message: formattedMessage,
parents: commit.parents,
stashNumber: commit.refType === 'stash' ? commit.number : undefined,
files: commit.files?.map(({ status, repoPath, path, originalPath, staged }) => {
const icon = getGitFileStatusIcon(status);
return {
path: path,
originalPath: originalPath,
status: status,
repoPath: repoPath,
icon: {
dark: this.host
.asWebviewUri(Uri.joinPath(this.host.getRootUri(), 'images', 'dark', icon))
.toString(),
light: this.host
.asWebviewUri(Uri.joinPath(this.host.getRootUri(), 'images', 'light', icon))
.toString(),
},
staged: staged,
};
}),
files: commit.files,
stats: commit.stats,
autolinks: autolinks != null ? [...map(autolinks.values(), serializeAutolink)] : undefined,
};
@ -848,7 +981,17 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
private async getFileCommitFromParams(
params: FileActionParams,
): Promise<[commit: GitCommit, file: GitFileChange] | undefined> {
const commit = await this._context.commit?.getCommitForFile(params.path, params.staged);
let commit: GitCommit | undefined;
if (this.mode === 'wip') {
const uri = this._context.wip?.changes?.repository.uri;
if (uri == null) return;
commit = await this.container.git.getCommit(Uri.parse(uri), uncommitted);
} else {
commit = this._context.commit;
}
commit = await commit?.getCommitForFile(params.path, params.staged);
return commit != null ? [commit, commit.file!] : undefined;
}
@ -856,10 +999,6 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
void executeCommand(Commands.ShowSettingsPageAndJumpToAutolinks);
}
private showCommitSearch() {
void executeGitCommand({ command: 'search', state: { openPickInView: true } });
}
private showCommitPicker() {
void executeGitCommand({
command: 'log',
@ -871,6 +1010,10 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
});
}
private showCommitSearch() {
void executeGitCommand({ command: 'search', state: { openPickInView: true } });
}
private showCommitActions() {
if (this._context.commit == null || this._context.commit.isUncommitted) return;
@ -887,6 +1030,23 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
void showDetailsQuickPick(commit, file);
}
private switchMode(params: SwitchModeParams) {
let repo;
if (params.mode === 'wip') {
let { repoPath } = params;
if (repoPath == null) {
repo = this.container.git.getBestRepositoryOrFirst();
if (repo == null) return;
repoPath = repo.path;
} else {
repo = this.container.git.getRepository(repoPath)!;
}
}
this.setMode(params.mode, repo);
}
private async openFileComparisonWithWorking(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
@ -939,6 +1099,24 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
void openFileOnRemote(file, commit);
}
private async stageFile(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
await this.container.git.stageFile(commit.repoPath, file.path);
}
private async unstageFile(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file] = result;
await this.container.git.unstageFile(commit.repoPath, file.path);
}
private getShowOptions(params: FileActionParams): TextDocumentShowOptions | undefined {
return params.showOptions;

+ 49
- 31
src/webviews/commitDetails/protocol.ts Visa fil

@ -6,6 +6,7 @@ import type { GitCommitIdentityShape, GitCommitStats } from '../../git/models/co
import type { GitFileChangeShape } from '../../git/models/file';
import type { IssueOrPullRequest } from '../../git/models/issue';
import type { PullRequestShape } from '../../git/models/pullRequest';
import type { DateTimeFormat } from '../../system/date';
import type { Serialized } from '../../system/serialize';
import { IpcCommandType, IpcNotificationType } from '../protocol';
@ -20,43 +21,58 @@ export interface CommitSummary {
message: string;
author: GitCommitIdentityShape & { avatar: string | undefined };
// committer: GitCommitIdentityShape & { avatar: string | undefined };
parents: string[];
repoPath: string;
stashNumber?: string;
}
export interface CommitDetails extends CommitSummary {
autolinks?: Autolink[];
files?: (GitFileChangeShape & { icon: { dark: string; light: string } })[];
files?: readonly GitFileChangeShape[];
stats?: GitCommitStats;
}
export interface Preferences {
autolinksExpanded?: boolean;
avatars?: boolean;
files?: Config['views']['commitDetails']['files'];
autolinksExpanded: boolean;
avatars: boolean;
dateFormat: DateTimeFormat | string;
files: Config['views']['commitDetails']['files'];
// indent: number;
indentGuides: 'none' | 'onHover' | 'always';
}
export type UpdateablePreferences = Partial<Pick<Preferences, 'autolinksExpanded' | 'files'>>;
export interface WipChange {
branchName: string;
repository: { name: string; path: string; uri: string };
files: GitFileChangeShape[];
}
export type Mode = 'commit' | 'wip';
export interface Wip {
changes: WipChange | undefined;
repositoryCount: number;
}
export interface State {
webviewId: WebviewIds | WebviewViewIds;
timestamp: number;
mode: Mode;
pinned: boolean;
preferences?: Preferences;
// commits?: CommitSummary[];
includeRichContent?: boolean;
selected?: CommitDetails;
autolinkedIssues?: IssueOrPullRequest[];
pullRequest?: PullRequestShape;
dateFormat: string;
// indent: number;
indentGuides: 'none' | 'onHover' | 'always';
navigationStack: {
count: number;
position: number;
hint?: string;
};
preferences: Preferences;
includeRichContent?: boolean;
commit?: CommitDetails;
autolinkedIssues?: IssueOrPullRequest[];
pullRequest?: PullRequestShape;
wip?: Wip;
}
export type ShowCommitDetailsViewCommandArgs = string[];
@ -82,11 +98,21 @@ export const OpenFileOnRemoteCommandType = new IpcCommandType(
export const OpenFileCompareWorkingCommandType = new IpcCommandType<FileActionParams>('commit/file/compareWorking');
export const OpenFileComparePreviousCommandType = new IpcCommandType<FileActionParams>('commit/file/comparePrevious');
export const StageFileCommandType = new IpcCommandType<FileActionParams>('commit/file/stage');
export const UnstageFileCommandType = new IpcCommandType<FileActionParams>('commit/file/unstage');
export const PickCommitCommandType = new IpcCommandType<undefined>('commit/pickCommit');
export const SearchCommitCommandType = new IpcCommandType<undefined>('commit/searchCommit');
export interface SwitchModeParams {
repoPath?: string;
mode: Mode;
}
export const SwitchModeCommandType = new IpcCommandType<SwitchModeParams>('commit/switchMode');
export const AutolinkSettingsCommandType = new IpcCommandType<undefined>('commit/autolinkSettings');
export const ExplainCommitCommandType = new IpcCommandType<undefined>('commit/explain');
export const ExplainCommandType = new IpcCommandType<undefined>('commit/explain');
export interface PinParams {
pin: boolean;
@ -98,12 +124,8 @@ export interface NavigateParams {
}
export const NavigateCommitCommandType = new IpcCommandType<NavigateParams>('commit/navigate');
export interface PreferenceParams {
autolinksExpanded?: boolean;
avatars?: boolean;
files?: Config['views']['commitDetails']['files'];
}
export const PreferencesCommandType = new IpcCommandType<PreferenceParams>('commit/preferences');
export type UpdatePreferenceParams = UpdateablePreferences;
export const UpdatePreferencesCommandType = new IpcCommandType<UpdatePreferenceParams>('commit/preferences/update');
// NOTIFICATIONS
@ -112,19 +134,15 @@ export interface DidChangeParams {
}
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('commit/didChange', true);
export type DidChangeRichStateParams = {
formattedMessage?: string;
autolinkedIssues?: IssueOrPullRequest[];
pullRequest?: PullRequestShape;
};
export const DidChangeRichStateNotificationType = new IpcNotificationType<DidChangeRichStateParams>(
'commit/didChange/rich',
export type DidChangeWipStateParams = Pick<Serialized<State>, 'wip'>;
export const DidChangeWipStateNotificationType = new IpcNotificationType<DidChangeWipStateParams>(
'commit/didChange/wip',
);
export type DidExplainCommitParams =
export type DidExplainParams =
| {
summary: string | undefined;
error?: undefined;
}
| { error: { message: string } };
export const DidExplainCommitCommandType = new IpcNotificationType<DidExplainCommitParams>('commit/didExplain');
export const DidExplainCommandType = new IpcNotificationType<DidExplainParams>('commit/didExplain');

+ 2
- 2
src/webviews/commitDetails/registration.ts Visa fil

@ -19,7 +19,7 @@ export function registerCommitDetailsWebviewView(controller: WebviewsController)
const { CommitDetailsWebviewProvider } = await import(
/* webpackChunkName: "commitDetails" */ './commitDetailsWebview'
);
return new CommitDetailsWebviewProvider(container, host, { mode: 'default' });
return new CommitDetailsWebviewProvider(container, host, { attachedTo: 'default' });
},
);
}
@ -41,7 +41,7 @@ export function registerGraphDetailsWebviewView(controller: WebviewsController)
const { CommitDetailsWebviewProvider } = await import(
/* webpackChunkName: "commitDetails" */ './commitDetailsWebview'
);
return new CommitDetailsWebviewProvider(container, host, { mode: 'graph' });
return new CommitDetailsWebviewProvider(container, host, { attachedTo: 'graph' });
},
);
}

Laddar…
Avbryt
Spara