소스 검색

Adds staged/unstages groups on Commit Details

Converts file changed web components to Lit
main
Eric Amodio 1 년 전
부모
커밋
97316ef8a0
10개의 변경된 파일627개의 추가작업 그리고 489개의 파일을 삭제
  1. +11
    -10
      src/git/models/commit.ts
  2. +5
    -2
      src/git/models/file.ts
  3. +81
    -36
      src/git/models/status.ts
  4. +13
    -47
      src/views/nodes/UncommittedFilesNode.ts
  5. +91
    -42
      src/webviews/apps/commitDetails/components/commit-details-app.ts
  6. +158
    -124
      src/webviews/apps/shared/components/list/file-change-list-item.ts
  7. +47
    -36
      src/webviews/apps/shared/components/list/list-container.ts
  8. +208
    -181
      src/webviews/apps/shared/components/list/list-item.ts
  9. +12
    -11
      src/webviews/commitDetails/commitDetailsWebview.ts
  10. +1
    -0
      src/webviews/commitDetails/protocol.ts

+ 11
- 10
src/git/models/commit.ts 파일 보기

@ -13,7 +13,7 @@ import { pad, pluralize } from '../../system/string';
import type { PreviousLineComparisonUrisResult } from '../gitProvider';
import { GitUri } from '../gitUri';
import type { RichRemoteProvider } from '../remotes/richRemoteProvider';
import { uncommitted } from './constants';
import { uncommitted, uncommittedStaged } from './constants';
import type { GitFile } from './file';
import { GitFileChange, GitFileWorkingTreeStatus } from './file';
import type { PullRequest } from './pullRequest';
@ -209,9 +209,7 @@ export class GitCommit implements GitRevisionReference {
if (this._etagFileSystem != null) {
const status = await this.container.git.getStatusForRepo(this.repoPath);
if (status != null) {
this._files = status.files.map(
f => new GitFileChange(this.repoPath, f.path, f.status, f.originalPath),
);
this._files = status.files.flatMap(f => f.getPseudoFileChanges());
}
this._etagFileSystem = repository?.etagFileSystem;
}
@ -322,15 +320,18 @@ export class GitCommit implements GitRevisionReference {
this._stats = { ...this._stats, changedFiles: changedFiles, additions: additions, deletions: deletions };
}
async findFile(path: string): Promise<GitFileChange | undefined>;
async findFile(uri: Uri): Promise<GitFileChange | undefined>;
async findFile(pathOrUri: string | Uri): Promise<GitFileChange | undefined> {
async findFile(path: string, staged?: boolean): Promise<GitFileChange | undefined>;
async findFile(uri: Uri, staged?: boolean): Promise<GitFileChange | undefined>;
async findFile(pathOrUri: string | Uri, staged?: boolean): Promise<GitFileChange | undefined> {
if (!this.hasFullDetails()) {
await this.ensureFullDetails();
if (this._files == null) return undefined;
}
const relativePath = this.container.git.getRelativePath(pathOrUri, this.repoPath);
if (this.isUncommitted && staged != null) {
return this._files?.find(f => f.path === relativePath && f.staged === staged);
}
return this._files?.find(f => f.path === relativePath);
}
@ -443,12 +444,12 @@ export class GitCommit implements GitRevisionReference {
return this.author.getCachedAvatarUri(options);
}
async getCommitForFile(file: string | GitFile): Promise<GitCommit | undefined> {
async getCommitForFile(file: string | GitFile, staged?: boolean): Promise<GitCommit | undefined> {
const path = typeof file === 'string' ? this.container.git.getRelativePath(file, this.repoPath) : file.path;
const foundFile = await this.findFile(path);
const foundFile = await this.findFile(path, staged);
if (foundFile == null) return undefined;
const commit = this.with({ files: { file: foundFile } });
const commit = this.with({ sha: foundFile.staged ? uncommittedStaged : this.sha, files: { file: foundFile } });
return commit;
}

+ 5
- 2
src/git/models/file.ts 파일 보기

@ -172,10 +172,12 @@ export interface GitFileChangeStats {
}
export interface GitFileChangeShape {
readonly repoPath: string;
readonly path: string;
readonly originalPath?: string | undefined;
readonly status: GitFileStatus;
readonly repoPath: string;
readonly originalPath?: string | undefined;
readonly staged?: boolean;
}
export class GitFileChange implements GitFileChangeShape {
@ -190,6 +192,7 @@ export class GitFileChange implements GitFileChangeShape {
public readonly originalPath?: string | undefined,
public readonly previousSha?: string | undefined,
public readonly stats?: GitFileChangeStats | undefined,
public readonly staged?: boolean,
) {}
get hasConflicts() {

+ 81
- 36
src/git/models/status.ts 파일 보기

@ -409,14 +409,11 @@ export class GitStatusFile implements GitFile {
return this.conflictStatus != null;
}
get edited() {
return this.workingTreeStatus != null;
}
get staged() {
return this.indexStatus != null;
}
@memoize()
get status(): GitFileStatus {
return (this.conflictStatus ?? this.indexStatus ?? this.workingTreeStatus)!;
}
@ -426,6 +423,10 @@ export class GitStatusFile implements GitFile {
return Container.instance.git.getAbsoluteUri(this.path, this.repoPath);
}
get wip() {
return this.workingTreeStatus != null;
}
getFormattedDirectory(includeOriginal: boolean = false): string {
return getGitFileFormattedDirectory(this, includeOriginal);
}
@ -443,12 +444,10 @@ export class GitStatusFile implements GitFile {
}
getPseudoCommits(container: Container, user: GitUser | undefined): GitCommit[] {
const commits: GitCommit[] = [];
const now = new Date();
if (this.conflictStatus != null) {
commits.push(
if (this.conflicted) {
return [
new GitCommit(
container,
this.repoPath,
@ -456,23 +455,28 @@ export class GitStatusFile implements GitFile {
new GitCommitIdentity('You', user?.email ?? undefined, now),
new GitCommitIdentity('You', user?.email ?? undefined, now),
'Uncommitted changes',
[uncommittedStaged],
['HEAD'],
'Uncommitted changes',
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, uncommittedStaged),
new GitFileChange(
this.repoPath,
this.path,
this.status,
this.originalPath,
'HEAD',
undefined,
false,
),
undefined,
[],
),
);
return commits;
];
}
if (this.workingTreeStatus == null && this.indexStatus == null) return commits;
if (this.workingTreeStatus != null && this.indexStatus != null) {
// Decrements the date to guarantee the staged entry will be sorted after the working entry (most recent first)
const older = new Date(now);
older.setMilliseconds(older.getMilliseconds() - 1);
const commits: GitCommit[] = [];
const staged = this.staged;
if (this.wip) {
const previousSha = staged ? uncommittedStaged : 'HEAD';
commits.push(
new GitCommit(
container,
@ -481,38 +485,46 @@ export class GitStatusFile implements GitFile {
new GitCommitIdentity('You', user?.email ?? undefined, now),
new GitCommitIdentity('You', user?.email ?? undefined, now),
'Uncommitted changes',
[uncommittedStaged],
[previousSha],
'Uncommitted changes',
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, uncommittedStaged),
undefined,
[],
),
new GitCommit(
container,
this.repoPath,
uncommittedStaged,
new GitCommitIdentity('You', user?.email ?? undefined, older),
new GitCommitIdentity('You', user?.email ?? undefined, older),
'Uncommitted changes',
['HEAD'],
'Uncommitted changes',
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD'),
new GitFileChange(
this.repoPath,
this.path,
this.status,
this.originalPath,
previousSha,
undefined,
false,
),
undefined,
[],
),
);
} else {
// Decrements the date to guarantee the staged entry (if exists) will be sorted after the working entry (most recent first)
now.setMilliseconds(now.getMilliseconds() - 1);
}
if (staged) {
commits.push(
new GitCommit(
container,
this.repoPath,
this.workingTreeStatus != null ? uncommitted : uncommittedStaged,
uncommittedStaged,
new GitCommitIdentity('You', user?.email ?? undefined, now),
new GitCommitIdentity('You', user?.email ?? undefined, now),
'Uncommitted changes',
['HEAD'],
'Uncommitted changes',
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD'),
new GitFileChange(
this.repoPath,
this.path,
this.status,
this.originalPath,
'HEAD',
undefined,
true,
),
undefined,
[],
),
@ -521,4 +533,37 @@ export class GitStatusFile implements GitFile {
return commits;
}
getPseudoFileChanges(): GitFileChange[] {
if (this.conflicted) {
return [
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD', undefined, false),
];
}
const files: GitFileChange[] = [];
const staged = this.staged;
if (this.wip) {
files.push(
new GitFileChange(
this.repoPath,
this.path,
this.status,
this.originalPath,
staged ? uncommittedStaged : 'HEAD',
undefined,
false,
),
);
}
if (staged) {
files.push(
new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD', undefined, true),
);
}
return files;
}
}

+ 13
- 47
src/views/nodes/UncommittedFilesNode.ts 파일 보기

@ -2,10 +2,7 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { ViewFilesLayout } from '../../config';
import { GitUri } from '../../git/gitUri';
import type { GitTrackingState } from '../../git/models/branch';
import { GitCommit, GitCommitIdentity } from '../../git/models/commit';
import { uncommitted, uncommittedStaged } from '../../git/models/constants';
import type { GitFileWithCommit } from '../../git/models/file';
import { GitFileChange } from '../../git/models/file';
import type { GitStatus, GitStatusFile } from '../../git/models/status';
import { groupBy, makeHierarchical } from '../../system/array';
import { flatMap } from '../../system/iterable';
@ -48,20 +45,19 @@ export class UncommittedFilesNode extends ViewNode {
const files: GitFileWithCommit[] = [
...flatMap(this.status.files, f => {
if (f.workingTreeStatus != null && f.indexStatus != null) {
// Decrements the date to guarantee this entry will be sorted after the previous entry (most recent first)
const older = new Date();
older.setMilliseconds(older.getMilliseconds() - 1);
return [
this.getFileWithPseudoCommit(f, uncommitted, uncommittedStaged),
this.getFileWithPseudoCommit(f, uncommittedStaged, 'HEAD', older),
];
} else if (f.indexStatus != null) {
return [this.getFileWithPseudoCommit(f, uncommittedStaged, 'HEAD')];
}
return [this.getFileWithPseudoCommit(f, uncommitted, 'HEAD')];
const commits = f.getPseudoCommits(this.view.container, undefined);
return commits.map(
c =>
({
status: f.status,
repoPath: f.repoPath,
indexStatus: f.indexStatus,
workingTreeStatus: f.workingTreeStatus,
path: f.path,
originalPath: f.originalPath,
commit: c,
}) satisfies GitFileWithCommit,
);
}),
];
@ -102,34 +98,4 @@ export class UncommittedFilesNode extends ViewNode {
return item;
}
private getFileWithPseudoCommit(
file: GitStatusFile,
ref: string,
previousRef: string,
date?: Date,
): GitFileWithCommit {
date = date ?? new Date();
return {
status: file.status,
repoPath: file.repoPath,
indexStatus: file.indexStatus,
workingTreeStatus: file.workingTreeStatus,
path: file.path,
originalPath: file.originalPath,
commit: new GitCommit(
this.view.container,
file.repoPath,
ref,
new GitCommitIdentity('You', undefined, date),
new GitCommitIdentity('You', undefined, date),
'Uncommitted changes',
[previousRef],
'Uncommitted changes',
new GitFileChange(file.repoPath, file.path, file.status, file.originalPath, previousRef),
undefined,
[],
),
};
}
}

+ 91
- 42
src/webviews/apps/commitDetails/components/commit-details-app.ts 파일 보기

@ -1,3 +1,4 @@
import type { TemplateResult } from 'lit';
import { html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
@ -10,6 +11,9 @@ import type { State } from '../../../commitDetails/protocol';
import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol';
import { uncommittedSha } from '../commitDetails';
type Files = NonNullable<NonNullable<State['selected']>['files']>;
type File = Files[0];
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
@ -344,59 +348,104 @@ export class GlCommitDetailsApp extends LitElement {
}
private renderFileList() {
return html`<list-container>
${this.state!.selected!.files!.map(
(file: Record<string, any>) => html`
<file-change-list-item
?stash=${this.isStash}
?uncommitted=${this.isUncommitted}
path="${file.path}"
repo="${file.repoPath}"
icon="${file.icon.dark}"
status="${file.status}"
></file-change-list-item>
`,
)}
</list-container>`;
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 hideIcon>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 hideIcon>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 hideIcon>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 hideIcon>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(
this.state!.selected!.files!,
files,
n => n.path.split('/'),
(...parts: string[]) => parts.join('/'),
this.state!.preferences?.files?.compact ?? true,
compact,
);
const flatTree = flattenHeirarchy(tree);
return html`<list-container class="indentGuides-${this.state!.indentGuides}">
${flatTree.map(({ level, item }) => {
if (item.name === '') {
return undefined;
}
if (item.value == null) {
return html`
<list-item level="${level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
}
return flatTree.map(({ level, item }) => {
if (item.name === '') return undefined;
if (item.value == null) {
return html`
<file-change-list-item
tree
level="${level}"
?stash=${this.isStash}
?uncommitted=${this.isUncommitted}
path="${item.value.path}"
repo="${item.value.repoPath}"
icon="${item.value.icon.dark}"
status="${item.value.status}"
></file-change-list-item>
<list-item level="${rootLevel + level}" tree branch>
<code-icon slot="icon" icon="folder" title="Directory" aria-label="Directory"></code-icon>
${item.name}
</list-item>
`;
})}
</list-container>`;
}
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() {

+ 158
- 124
src/webviews/apps/shared/components/list/file-change-list-item.ts 파일 보기

@ -1,6 +1,8 @@
import { attr, css, customElement, FASTElement, html, ref, volatile, when } from '@microsoft/fast-element';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { Ref } from 'lit/directives/ref.js';
import { createRef, ref } from 'lit/directives/ref.js';
import type { TextDocumentShowOptions } from 'vscode';
import { numberConverter } from '../converters/number-converter';
import type { ListItem, ListItemSelectedEvent } from './list-item';
import '../code-icon';
@ -10,92 +12,12 @@ const BesideViewColumn = -2; /*ViewColumn.Beside*/
export interface FileChangeListItemDetail {
path: string;
repoPath: string;
staged: boolean | undefined;
showOptions?: TextDocumentShowOptions;
}
// TODO: "change-list__action" should be a separate component
const template = html<FileChangeListItem>`
<list-item
${ref('base')}
tree="${x => x.tree}"
level="${x => x.level}"
active="${x => x.active}"
expanded="${x => x.expanded}"
parentexpanded="${x => x.parentexpanded}"
@selected="${(x, c) => x.onComparePrevious(c.event as ListItemSelectedEvent)}"
>
<img slot="icon" src="${x => x.icon}" title="${x => x.statusName}" alt="${x => x.statusName}" />
${x => x.fileName}
${when(x => !x.tree, html<FileChangeListItem>`<span slot="description">${x => x.filePath}</span>`)}
<span slot="actions">
<a
class="change-list__action"
@click="${(x, c) => x.onOpenFile(c.event as MouseEvent)}"
href="#"
title="Open file"
aria-label="Open file"
><code-icon icon="go-to-file"></code-icon
></a>
${when(
x => !x.uncommitted,
html<FileChangeListItem>`
<a
class="change-list__action"
@click="${(x, c) => x.onCompareWorking(c.event as MouseEvent)}"
href="#"
title="Open Changes with Working File"
aria-label="Open Changes with Working File"
><code-icon icon="git-compare"></code-icon
></a>
${when(
x => !x.stash,
html<FileChangeListItem>`<a
class="change-list__action"
@click="${(x, c) => x.onOpenFileOnRemote(c.event as MouseEvent)}"
href="#"
title="Open on remote"
aria-label="Open on remote"
><code-icon icon="globe"></code-icon></a
><a
class="change-list__action"
@click="${(x, c) => x.onMoreActions(c.event as MouseEvent)}"
href="#"
title="Show more actions"
aria-label="Show more actions"
><code-icon icon="ellipsis"></code-icon
></a>`,
)}
`,
)}
</span>
</list-item>
`;
const styles = css`
.change-list__action {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.25em;
color: inherit;
padding: 2px;
vertical-align: text-bottom;
text-decoration: none;
}
.change-list__action:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.change-list__action:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.change-list__action:active {
background-color: var(--vscode-toolbar-activeBackground);
}
`;
// TODO: use the model version
const statusTextMap: Record<string, string> = {
@ -118,111 +40,223 @@ const statusTextMap: Record = {
U: 'Updated but Unmerged',
};
@customElement({ name: 'file-change-list-item', template: template, styles: styles })
export class FileChangeListItem extends FASTElement {
base?: ListItem;
@customElement('file-change-list-item')
export class FileChangeListItem extends LitElement {
static override styles = css`
.change-list__action {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.25em;
color: inherit;
padding: 2px;
vertical-align: text-bottom;
text-decoration: none;
}
.change-list__action:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.change-list__action:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.change-list__action:active {
background-color: var(--vscode-toolbar-activeBackground);
}
`;
@attr({ mode: 'boolean' })
baseRef: Ref<ListItem> = createRef();
@property({ type: Boolean })
tree = false;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
expanded = true;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
parentexpanded = true;
@attr({ converter: numberConverter })
@property({ type: Number })
level = 1;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
active = false;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
stash = false;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
uncommitted = false;
@attr
@property(ass="p">{ type: String })
icon = '';
@attr
@property(ass="p">{ type: String })
path = '';
@attr
@property(ass="p">{ type: String })
repo = '';
@attr
@property({ type: Boolean })
staged = false;
@property({ type: String })
status = '';
select(showOptions?: TextDocumentShowOptions) {
this.base?.select(showOptions);
this.baseRef.value?.select(showOptions);
}
deselect() {
this.base?.deselect();
this.baseRef.value?.deselect();
}
override focus(options?: FocusOptions | undefined): void {
this.base?.focus(options);
this.baseRef.value?.focus(options);
}
@state()
get isHidden() {
return this.base?.isHidden ?? 'false';
return this.baseRef.value?.isHidden ?? 'false';
}
@state()
get pathIndex() {
return this.path.lastIndexOf('/');
}
@volatile
@state()
get fileName() {
return this.pathIndex > -1 ? this.path.substring(this.pathIndex + 1) : this.path;
}
@volatile
@state()
get filePath() {
return !this.tree && this.pathIndex > -1 ? this.path.substring(0, this.pathIndex) : '';
}
@state()
get statusName() {
return this.status !== '' ? statusTextMap[this.status] : '';
}
private getEventDetail(showOptions?: TextDocumentShowOptions): FileChangeListItemDetail {
return {
path: this.path,
repoPath: this.repo,
showOptions: showOptions,
};
override firstUpdated(): void {
if (this.parentexpanded !== false) {
this.setAttribute('parentexpanded', '');
}
if (this.expanded !== false) {
this.setAttribute('expanded', '');
}
}
override render() {
return html`
<list-item
${ref(this.baseRef)}
?tree=${this.tree}
level=${this.level}
?active=${this.active}
?expanded=${this.expanded}
?parentexpanded=${this.parentexpanded}
@selected=${this.onComparePrevious}
>
<img slot="icon" .src=${this.icon} .title=${this.statusName} .alt=${this.statusName} />
${this.fileName} ${this.tree ? nothing : html`<span slot="description">${this.filePath}</span>`}
<span slot="actions">
<a
class="change-list__action"
@click=${this.onOpenFile}
href="#"
title="Open file"
aria-label="Open file"
>
<code-icon icon="go-to-file"></code-icon>
</a>
${this.uncommitted
? nothing
: html`
<a
class="change-list__action"
@click=${this.onCompareWorking}
href="#"
title="Open Changes with Working File"
aria-label="Open Changes with Working File"
>
<code-icon icon="git-compare"></code-icon>
</a>
${this.stash
? nothing
: html`
<a
class="change-list__action"
@click=${this.onOpenFileOnRemote}
href="#"
title="Open on remote"
aria-label="Open on remote"
>
<code-icon icon="globe"></code-icon>
</a>
<a
class="change-list__action"
@click=${this.onMoreActions}
href="#"
title="Show more actions"
aria-label="Show more actions"
>
<code-icon icon="ellipsis"></code-icon>
</a>
`}
`}
</span>
</list-item>
`;
}
onOpenFile(e: MouseEvent) {
this.$emit(
'file-open',
this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
);
const event = new CustomEvent('file-open', {
detail: this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
});
this.dispatchEvent(event);
}
onOpenFileOnRemote(e: MouseEvent) {
this.$emit(
'file-open-on-remote',
this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
);
const event = new CustomEvent('file-open-on-remote', {
detail: this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
});
this.dispatchEvent(event);
}
onCompareWorking(e: MouseEvent) {
this.$emit(
'file-compare-working',
this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
);
const event = new CustomEvent('file-compare-working', {
detail: this.getEventDetail({ preview: false, viewColumn: e.altKey ? BesideViewColumn : undefined }),
});
this.dispatchEvent(event);
}
onComparePrevious(e: ListItemSelectedEvent) {
this.$emit('file-compare-previous', this.getEventDetail(e.detail.showOptions));
const event = new CustomEvent('file-compare-previous', {
detail: this.getEventDetail(e.detail.showOptions),
});
this.dispatchEvent(event);
}
onMoreActions(_e: MouseEvent) {
this.$emit('file-more-actions', this.getEventDetail());
const event = new CustomEvent('file-more-actions', {
detail: this.getEventDetail(),
});
this.dispatchEvent(event);
}
private getEventDetail(showOptions?: TextDocumentShowOptions): FileChangeListItemDetail {
return {
path: this.path,
repoPath: this.repo,
staged: this.staged,
showOptions: showOptions,
};
}
}

+ 47
- 36
src/webviews/apps/shared/components/list/list-container.ts 파일 보기

@ -1,42 +1,29 @@
import { css, customElement, FASTElement, html, observable, slotted } from '@microsoft/fast-element';
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import type { FileChangeListItem } from './file-change-list-item';
import type { ListItem, ListItemSelectedEvent } from './list-item';
// Can only import types from 'vscode'
const BesideViewColumn = -2; /*ViewColumn.Beside*/
const template = html<ListContainer>`
<template role="tree">
<slot ${slotted('itemNodes')}></slot>
</template>
`;
const styles = css`
::slotted(*) {
box-sizing: inherit;
}
`;
type ListItemTypes = ListItem | FileChangeListItem;
@customElement({ name: 'list-container', template: template, styles: styles })
export class ListContainer extends FASTElement {
private _lastSelected: ListItem | undefined;
@customElement('list-container')
export class ListContainer extends LitElement {
static override styles = css`
::slotted(*) {
box-sizing: inherit;
}
`;
@observable
itemNodes?: ListItemTypes[];
private _lastSelected!: ListItem | undefined;
private _slotSubscriptionsDisposer?: () => void;
itemNodesDisposer?: () => void;
handleSlotChange(e: Event) {
this._slotSubscriptionsDisposer?.();
itemNodesChanged(_oldValue?: ListItemTypes[], newValue?: ListItemTypes[]) {
this.itemNodesDisposer?.();
const nodes = (e.target as HTMLSlotElement).assignedNodes();
if (!nodes?.length) return;
if (!newValue?.length) {
return;
}
const nodeEvents = newValue
?.filter(node => node.nodeType === 1)
.map(node => {
const subscriptions = (nodes?.filter(node => node.nodeType === 1) as (ListItem | FileChangeListItem)[]).map(
node => {
const keyHandler = this.handleKeydown.bind(this);
const beforeSelectHandler = this.handleBeforeSelected.bind(this);
const selectHandler = this.handleSelected.bind(this);
@ -51,15 +38,18 @@ export class ListContainer extends FASTElement {
node?.removeEventListener('selected', selectHandler, false);
},
};
});
},
);
this.itemNodesDisposer = () => {
nodeEvents?.forEach(({ dispose }) => dispose());
this._slotSubscriptionsDisposer = () => {
subscriptions?.forEach(({ dispose }) => dispose());
};
}
override disconnectedCallback() {
this.itemNodesDisposer?.();
super.disconnectedCallback();
this._slotSubscriptionsDisposer?.();
}
handleBeforeSelected(e: Event) {
@ -100,8 +90,19 @@ export class ListContainer extends FASTElement {
if (level == getLevel(nextElement)) break;
const parentElement = getParent(nextElement);
nextElement.setAttribute('parentexpanded', parentElement?.expanded === false ? 'false' : 'true');
nextElement.setAttribute('expanded', e.detail.expanded ? 'true' : 'false');
if (parentElement?.expanded === false) {
nextElement.removeAttribute('parentexpanded');
} else {
nextElement.setAttribute('parentexpanded', '');
}
if (e.detail.expanded) {
nextElement.setAttribute('expanded', '');
} else {
nextElement.removeAttribute('expanded');
}
nextElement = nextElement.nextElementSibling as ListItem;
}
}
@ -123,4 +124,14 @@ export class ListContainer extends FASTElement {
$next?.focus();
}
}
override firstUpdated() {
this.setAttribute('role', 'tree');
this.shadowRoot?.querySelector('slot')?.addEventListener('slotchange', this.handleSlotChange.bind(this));
}
override render() {
return html`<slot></slot>`;
}
}

+ 208
- 181
src/webviews/apps/shared/components/list/list-item.ts 파일 보기

@ -1,6 +1,8 @@
import { attr, css, customElement, FASTElement, html, repeat, volatile, when } from '@microsoft/fast-element';
import type { PropertyValues } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { TextDocumentShowOptions } from 'vscode';
import { numberConverter } from '../converters/number-converter';
import '../converters/number-converter';
import '../code-icon';
// Can only import types from 'vscode'
@ -22,199 +24,161 @@ export interface ListItemSelectedEventDetail {
showOptions?: TextDocumentShowOptions;
}
const template = html<ListItem>`
<template
role="treeitem"
aria-expanded="${x => (x.expanded === true ? 'true' : 'false')}"
aria-hidden="${x => x.isHidden}"
>
<button
id="item"
class="item"
type="button"
@click="${(x, c) => x.onItemClick(c.event as MouseEvent)}"
@dblclick="${(x, c) => x.onDblItemClick(c.event as MouseEvent)}"
>
${repeat(
x => x.treeLeaves,
html<ListItem>`<span class="node node--connector"><code-icon name="blank"></code-icon></span>`,
)}
${when(
x => x.branch,
html<ListItem>`<span class="node"
><code-icon
class="branch"
icon="${x => (x.expanded ? 'chevron-down' : 'chevron-right')}"
></code-icon
></span>`,
)}
<span class="icon"><slot name="icon"></slot></span>
<span class="text">
<span class="main"><slot></slot></span>
<span class="description"><slot name="description"></slot></span>
</span>
</button>
<nav class="actions"><slot name="actions"></slot></nav>
</template>
`;
const styles = css`
:host {
box-sizing: border-box;
padding-left: var(--gitlens-gutter-width);
padding-right: var(--gitlens-scrollbar-gutter-width);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
line-height: 2.2rem;
height: 2.2rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-size: var(--vscode-font-size);
color: var(--vscode-sideBar-foreground);
content-visibility: auto;
contain-intrinsic-size: auto 2.2rem;
}
@customElement('list-item')
export class ListItem extends LitElement {
static override styles = css`
:host {
box-sizing: border-box;
padding-left: var(--gitlens-gutter-width);
padding-right: var(--gitlens-scrollbar-gutter-width);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
line-height: 2.2rem;
height: 2.2rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-size: var(--vscode-font-size);
color: var(--vscode-sideBar-foreground);
content-visibility: auto;
contain-intrinsic-size: auto 2.2rem;
}
:host(:hover) {
color: var(--vscode-list-hoverForeground);
background-color: var(--vscode-list-hoverBackground);
}
:host(:hover) {
color: var(--vscode-list-hoverForeground);
background-color: var(--vscode-list-hoverBackground);
}
:host([active]) {
color: var(--vscode-list-inactiveSelectionForeground);
background-color: var(--vscode-list-inactiveSelectionBackground);
}
:host([active]) {
color: var(--vscode-list-inactiveSelectionForeground);
background-color: var(--vscode-list-inactiveSelectionBackground);
}
:host(:focus-within) {
outline: 1px solid var(--vscode-list-focusOutline);
outline-offset: -0.1rem;
color: var(--vscode-list-activeSelectionForeground);
background-color: var(--vscode-list-activeSelectionBackground);
}
:host(:focus-within) {
outline: 1px solid var(--vscode-list-focusOutline);
outline-offset: -0.1rem;
color: var(--vscode-list-activeSelectionForeground);
background-color: var(--vscode-list-activeSelectionBackground);
}
:host([aria-hidden='true']) {
display: none;
}
:host([aria-hidden='true']) {
display: none;
}
* {
box-sizing: border-box;
}
* {
box-sizing: border-box;
}
.item {
appearance: none;
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 0.6rem;
width: 100%;
padding: 0;
text-decoration: none;
color: inherit;
background: none;
border: none;
outline: none;
cursor: pointer;
min-width: 0;
}
.item {
appearance: none;
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: 0.6rem;
width: 100%;
padding: 0;
text-decoration: none;
color: inherit;
background: none;
border: none;
outline: none;
cursor: pointer;
min-width: 0;
}
.icon {
display: inline-block;
width: 1.6rem;
text-align: center;
}
.icon {
display: inline-block;
width: 1.6rem;
text-align: center;
}
slot[name='icon']::slotted(*) {
width: 1.6rem;
aspect-ratio: 1;
vertical-align: text-bottom;
}
slot[name='icon']::slotted(*) {
width: 1.6rem;
aspect-ratio: 1;
vertical-align: text-bottom;
}
.node {
display: inline-block;
width: 1.6rem;
text-align: center;
}
.node {
display: inline-block;
width: 1.6rem;
text-align: center;
}
.node--connector {
position: relative;
}
.node--connector::before {
content: '';
position: absolute;
height: 2.2rem;
border-left: 1px solid transparent;
top: 50%;
transform: translate(-50%, -50%);
left: 0.8rem;
width: 0.1rem;
transition: border-color 0.1s linear;
opacity: 0.4;
}
.node--connector {
position: relative;
}
.node--connector::before {
content: '';
position: absolute;
height: 2.2rem;
border-left: 1px solid transparent;
top: 50%;
transform: translate(-50%, -50%);
left: 0.8rem;
width: 0.1rem;
transition: border-color 0.1s linear;
opacity: 0.4;
}
:host-context(.indentGuides-always) .node--connector::before,
:host-context(.indentGuides-onHover:focus-within) .node--connector::before,
:host-context(.indentGuides-onHover:hover) .node--connector::before {
border-color: var(--vscode-tree-indentGuidesStroke);
}
:host-context(.indentGuides-always) .node--connector::before,
:host-context(.indentGuides-onHover:focus-within) .node--connector::before,
:host-context(.indentGuides-onHover:hover) .node--connector::before {
border-color: var(--vscode-tree-indentGuidesStroke);
}
.text {
overflow: hidden;
white-space: nowrap;
text-align: left;
text-overflow: ellipsis;
flex: 1;
}
.text {
overflow: hidden;
white-space: nowrap;
text-align: left;
text-overflow: ellipsis;
flex: 1;
}
.description {
opacity: 0.7;
margin-left: 0.3rem;
}
.description {
opacity: 0.7;
margin-left: 0.3rem;
}
.actions {
flex: none;
user-select: none;
color: var(--vscode-icon-foreground);
}
.actions {
flex: none;
user-select: none;
color: var(--vscode-icon-foreground);
}
:host(:focus-within) .actions {
color: var(--vscode-list-activeSelectionIconForeground);
}
:host(:focus-within) .actions {
color: var(--vscode-list-activeSelectionIconForeground);
}
:host(:not(:hover):not(:focus-within)) .actions {
display: none;
}
:host(:not(:hover):not(:focus-within)) .actions {
display: none;
}
slot[name='actions']::slotted(*) {
display: flex;
align-items: center;
}
`;
slot[name='actions']::slotted(*) {
display: flex;
align-items: center;
}
`;
@customElement({ name: 'list-item', template: template, styles: styles })
export class ListItem extends FASTElement {
@attr({ mode: 'boolean' })
tree = false;
@property({ type: Boolean }) tree = false;
@attr({ mode: 'boolean' })
branch = false;
@property({ type: Boolean }) branch = false;
@attr({ mode: 'boolean' })
expanded = true;
@property({ type: Boolean }) expanded = true;
@attr({ mode: 'boolean' })
parentexpanded = true;
@property({ type: Boolean }) parentexpanded = true;
@attr({ converter: numberConverter })
level = 1;
@property({ type: Number }) level = 1;
@attr({ mode: 'boolean' })
@property({ type: Boolean })
active = false;
@volatile
@property({ type: Boolean })
hideIcon = false;
@state()
get treeLeaves() {
const length = this.level - 1;
if (length < 1) return [];
@ -222,7 +186,7 @@ export class ListItem extends FASTElement {
return Array.from({ length: length }, (_, i) => i + 1);
}
@volatile
@state()
get isHidden(): 'true' | 'false' {
if (this.parentexpanded === false || (!this.branch && !this.expanded)) {
return 'true';
@ -243,9 +207,7 @@ export class ListItem extends FASTElement {
}
select(showOptions?: TextDocumentShowOptions, quiet = false) {
this.$emit('select');
// TODO: this needs to be implemented
this.dispatchEvent(new CustomEvent('select'));
if (this.branch) {
this.expanded = !this.expanded;
}
@ -253,13 +215,17 @@ export class ListItem extends FASTElement {
this.active = true;
if (!quiet) {
window.requestAnimationFrame(() => {
this.$emit('selected', {
tree: this.tree,
branch: this.branch,
expanded: this.expanded,
level: this.level,
showOptions: showOptions,
} satisfies ListItemSelectedEventDetail);
this.dispatchEvent(
new CustomEvent('selected', {
detail: {
tree: this.tree,
branch: this.branch,
expanded: this.expanded,
level: this.level,
showOptions: showOptions,
},
}),
);
});
}
}
@ -271,4 +237,65 @@ export class ListItem extends FASTElement {
override focus(options?: FocusOptions | undefined): void {
this.shadowRoot?.getElementById('item')?.focus(options);
}
override firstUpdated(_changedProperties: PropertyValues): void {
this.setAttribute('role', 'treeitem');
if (this.parentexpanded !== false) {
this.setAttribute('parentexpanded', '');
}
if (this.expanded !== false) {
this.setAttribute('expanded', '');
}
// this.shadowRoot
// ?.querySelector('slot[name="icon"]')
// ?.addEventListener('slotchange', this.handleIconSlotChange.bind(this));
}
// private _hasIcon = false;
// @state()
// get hasIcon() {
// return this._hasIcon;
// }
// handleIconSlotChange(e: Event) {
// this._hasIcon = (e.target as HTMLSlotElement).assignedNodes().length > 0;
// }
override updated() {
this.setAttribute('aria-expanded', this.expanded ? 'true' : 'false');
this.setAttribute('aria-hidden', this.isHidden);
}
override render() {
return html`
<button
id="item"
class="item"
type="button"
@click="${this.onItemClick}"
@dblclick="${this.onDblItemClick}"
>
${this.treeLeaves.map(
() => html`<span class="node node--connector"><code-icon name="blank"></code-icon></span>`,
)}
${this.branch
? html`<span class="node"
><code-icon
class="branch"
icon="${this.expanded ? 'chevron-down' : 'chevron-right'}"
></code-icon
></span>`
: nothing}
${this.hideIcon ? nothing : html`<span class="icon"><slot name="icon"></slot></span>`}
<span class="text">
<span class="main"><slot></slot></span>
<span class="description"><slot name="description"></slot></span>
</span>
</button>
<nav class="actions"><slot name="actions"></slot></nav>
`;
}
}

+ 12
- 11
src/webviews/commitDetails/commitDetailsWebview.ts 파일 보기

@ -576,15 +576,15 @@ 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();
}),
);
}
this._commitDisposable = Disposable.from(
repository.startWatchingFileSystem(),
repository.onDidChangeFileSystem(() => {
// this.updatePendingContext({ commit: undefined });
this.updatePendingContext({ commit: commit }, true);
this.updateState();
}),
);
}
}
this.updatePendingContext(
@ -814,7 +814,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
// committer: { ...commit.committer, avatar: committerAvatar?.toString(true) },
message: formattedMessage,
stashNumber: commit.refType === 'stash' ? commit.number : undefined,
files: commit.files?.map(({ status, repoPath, path, originalPath }) => {
files: commit.files?.map(({ status, repoPath, path, originalPath, staged }) => {
const icon = getGitFileStatusIcon(status);
return {
path: path,
@ -829,6 +829,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
.asWebviewUri(Uri.joinPath(this.host.getRootUri(), 'images', 'light', icon))
.toString(),
},
staged: staged,
};
}),
stats: commit.stats,
@ -860,7 +861,7 @@ 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);
const commit = await this._context.commit?.getCommitForFile(params.path, params.staged);
return commit != null ? [commit, commit.file!] : undefined;
}

+ 1
- 0
src/webviews/commitDetails/protocol.ts 파일 보기

@ -72,6 +72,7 @@ export const CommitActionsCommandType = new IpcCommandType(
export interface FileActionParams {
path: string;
repoPath: string;
staged: boolean | undefined;
showOptions?: TextDocumentShowOptions;
}

불러오는 중...
취소
저장