diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 840281a..7047f84 100644 --- a/src/git/models/commit.ts +++ b/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; - async findFile(uri: Uri): Promise; - async findFile(pathOrUri: string | Uri): Promise { + async findFile(path: string, staged?: boolean): Promise; + async findFile(uri: Uri, staged?: boolean): Promise; + async findFile(pathOrUri: string | Uri, staged?: boolean): Promise { 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 { + async getCommitForFile(file: string | GitFile, staged?: boolean): Promise { 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; } diff --git a/src/git/models/file.ts b/src/git/models/file.ts index cd3f506..9dacfeb 100644 --- a/src/git/models/file.ts +++ b/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() { diff --git a/src/git/models/status.ts b/src/git/models/status.ts index 2364edf..b30f784 100644 --- a/src/git/models/status.ts +++ b/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; + } } diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts index ab32f4e..911f0f9 100644 --- a/src/views/nodes/UncommittedFilesNode.ts +++ b/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, - [], - ), - }; - } } diff --git a/src/webviews/apps/commitDetails/components/commit-details-app.ts b/src/webviews/apps/commitDetails/components/commit-details-app.ts index 6a6f027..900bafe 100644 --- a/src/webviews/apps/commitDetails/components/commit-details-app.ts +++ b/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['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` - ${this.state!.selected!.files!.map( - (file: Record) => html` - - `, - )} - `; + 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`Staged Changes`); + + 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`Unstaged Changes`); + + for (const f of unstaged) { + items.push(this.renderFile(f, 2, true)); + } + } + } else { + items = files.map(f => this.renderFile(f)); + } + + return html`${items}`; } 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`Staged Changes`); + items.push(...this.renderFileSubtree(staged, 1, compact)); + } + + const unstaged = files.filter(f => !f.staged); + if (unstaged.length) { + items.push(html`Unstaged Changes`); + items.push(...this.renderFileSubtree(unstaged, 1, compact)); + } + } else { + items = this.renderFileSubtree(files, 0, compact); + } + + return html`${items}`; + } + + 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` - ${flatTree.map(({ level, item }) => { - if (item.name === '') { - return undefined; - } - - if (item.value == null) { - return html` - - - ${item.name} - - `; - } + return flatTree.map(({ level, item }) => { + if (item.name === '') return undefined; + if (item.value == null) { return html` - + + + ${item.name} + `; - })} - `; + } + + return this.renderFile(item.value, rootLevel + level, true); + }); + } + + private renderFile(file: File, level: number = 1, tree: boolean = false): TemplateResult<1> { + return html` + + `; } private renderChangedFiles() { diff --git a/src/webviews/apps/shared/components/list/file-change-list-item.ts b/src/webviews/apps/shared/components/list/file-change-list-item.ts index 1c3a65b..927acdb 100644 --- a/src/webviews/apps/shared/components/list/file-change-list-item.ts +++ b/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` - - ${x => x.statusName} - ${x => x.fileName} - ${when(x => !x.tree, html`${x => x.filePath}`)} - - - ${when( - x => !x.uncommitted, - html` - - ${when( - x => !x.stash, - html``, - )} - `, - )} - - -`; - -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 = { @@ -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 = 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({ type: String }) icon = ''; - @attr + @property({ type: String }) path = ''; - @attr + @property({ 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` + + + ${this.fileName} ${this.tree ? nothing : html`${this.filePath}`} + + + + + ${this.uncommitted + ? nothing + : html` + + + + ${this.stash + ? nothing + : html` + + + + + + + `} + `} + + + `; } 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, + }; } } diff --git a/src/webviews/apps/shared/components/list/list-container.ts b/src/webviews/apps/shared/components/list/list-container.ts index 423199b..e8ab8e8 100644 --- a/src/webviews/apps/shared/components/list/list-container.ts +++ b/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` - -`; - -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``; + } } diff --git a/src/webviews/apps/shared/components/list/list-item.ts b/src/webviews/apps/shared/components/list/list-item.ts index f810f19..ad16871 100644 --- a/src/webviews/apps/shared/components/list/list-item.ts +++ b/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` - -`; - -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` + + + `; + } } diff --git a/src/webviews/commitDetails/commitDetailsWebview.ts b/src/webviews/commitDetails/commitDetailsWebview.ts index 1b6fd48..59dc886 100644 --- a/src/webviews/commitDetails/commitDetailsWebview.ts +++ b/src/webviews/commitDetails/commitDetailsWebview.ts @@ -576,15 +576,15 @@ export class CommitDetailsWebviewProvider implements WebviewProvider { - // 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 { + 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 { - 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; } diff --git a/src/webviews/commitDetails/protocol.ts b/src/webviews/commitDetails/protocol.ts index b95f92c..f62d32b 100644 --- a/src/webviews/commitDetails/protocol.ts +++ b/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; }