diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9f273..9ab4922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- Adds new features to the _Commit Graph_ header + - Adds the current branch name with the ability to switch branches when clicked + - Adds a fetch action next to the branch name + - Moves the repo menu, account status and feedback links to the top + ### Fixed - Fixes [#2377](https://github.com/gitkraken/vscode-gitlens/issues/2377) - Missing Azure Devops Icon diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index b0084cc..b823361 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -47,12 +47,8 @@ import type { } from '../../../git/models/reference'; import { GitReference, GitRevision } from '../../../git/models/reference'; import { getRemoteIconUri } from '../../../git/models/remote'; -import type { - Repository, - RepositoryChangeEvent, - RepositoryFileSystemChangeEvent, -} from '../../../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../../git/models/repository'; +import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey } from '../../../git/search'; import type { StoredGraphHiddenRef } from '../../../storage'; @@ -60,7 +56,7 @@ import { executeActionCommand, executeCommand, executeCoreGitCommand, registerCo import { gate } from '../../../system/decorators/gate'; import { debug } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; -import { debounce, once } from '../../../system/function'; +import { debounce, disposableInterval, once } from '../../../system/function'; import { last } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; import { getSettledValue } from '../../../system/promise'; @@ -119,6 +115,7 @@ import { DidChangeSubscriptionNotificationType, DidChangeWorkingTreeNotificationType, DidEnsureRowNotificationType, + DidFetchNotificationType, DidSearchNotificationType, DismissBannerCommandType, DoubleClickedRefCommandType, @@ -201,6 +198,7 @@ export class GraphWebview extends WebviewBase { private _statusBarItem: StatusBarItem | undefined; private _theme: ColorTheme | undefined; private _repositoryEventsDisposable: Disposable | undefined; + private _lastFetchedDisposable: Disposable | undefined; private trialBanner?: boolean; @@ -1028,6 +1026,19 @@ export class GraphWebview extends WebviewBase { } @debug() + private async notifyDidFetch() { + if (!this.isReady || !this.visible) { + this.addPendingIpcNotification(DidFetchNotificationType); + return false; + } + + const lastFetched = await this.repository!.getLastFetched(); + return this.notify(DidFetchNotificationType, { + lastFetched: new Date(lastFetched), + }); + } + + @debug() private async notifyDidChangeRows(sendSelectedRows: boolean = false, completionId?: string) { if (this._graph == null) return; @@ -1161,6 +1172,7 @@ export class GraphWebview extends WebviewBase { } private ensureRepositorySubscriptions(force?: boolean) { + void this.ensureLastFetchedSubscription(force); if (!force && this._repositoryEventsDisposable != null) return; if (this._repositoryEventsDisposable != null) { @@ -1184,6 +1196,33 @@ export class GraphWebview extends WebviewBase { ); } + private async ensureLastFetchedSubscription(force?: boolean) { + if (!force && this._lastFetchedDisposable != null) return; + + if (this._lastFetchedDisposable != null) { + this._lastFetchedDisposable.dispose(); + this._lastFetchedDisposable = undefined; + } + + const repo = this.repository; + if (repo == null) return; + + const lastFetched = (await repo.getLastFetched()) ?? 0; + + let interval = Repository.getLastFetchedUpdateInterval(lastFetched); + if (lastFetched !== 0 && interval > 0) { + this._lastFetchedDisposable = disposableInterval(() => { + // Check if the interval should change, and if so, reset it + const checkInterval = Repository.getLastFetchedUpdateInterval(lastFetched); + if (interval !== Repository.getLastFetchedUpdateInterval(lastFetched)) { + interval = checkInterval; + } + + void this.notifyDidFetch(); + }, interval); + } + } + private async ensureSearchStartsInRange(graph: GitGraph, search: GitSearch) { if (search.results.size === 0) return undefined; @@ -1407,11 +1446,16 @@ export class GraphWebview extends WebviewBase { const columns = this.getColumns(); + const lastFetched = await this.repository.getLastFetched(); + const branch = await this.repository.getBranch(); + return { trialBanner: this.trialBanner, repositories: formatRepositories(this.container.git.openRepositories), selectedRepository: this.repository.path, selectedRepositoryVisibility: visibility, + branchName: branch?.name, + lastFetched: new Date(lastFetched), selectedRows: this._selectedRows, subscription: access?.subscription.current, allowed: (access?.allowed ?? false) !== false, diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index 265c422..a0d35d9 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -41,6 +41,8 @@ export interface State { repositories?: GraphRepository[]; selectedRepository?: string; selectedRepositoryVisibility?: RepositoryVisibility; + branchName?: string; + lastFetched?: Date; selectedRows?: GraphSelectedRows; subscription?: Subscription; allowed: boolean; @@ -299,3 +301,8 @@ export interface DidSearchParams { selectedRows?: GraphSelectedRows; } export const DidSearchNotificationType = new IpcNotificationType('graph/didSearch', true); + +export interface DidFetchParams { + lastFetched: Date; +} +export const DidFetchNotificationType = new IpcNotificationType('graph/didFetch'); diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index 13b70d3..c11980e 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -43,12 +43,15 @@ import { DidChangeSelectionNotificationType, DidChangeSubscriptionNotificationType, DidChangeWorkingTreeNotificationType, + DidFetchNotificationType, DidSearchNotificationType, } from '../../../../plus/webviews/graph/protocol'; import type { Subscription } from '../../../../subscription'; import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription'; import { pluralize } from '../../../../system/string'; import type { IpcNotificationType } from '../../../../webviews/protocol'; +import { MenuItem, MenuList } from '../../shared/components/menu/react'; +import { PopMenu } from '../../shared/components/overlays/pop-menu/react'; import { PopOver } from '../../shared/components/overlays/react'; import { SearchBox } from '../../shared/components/search/react'; import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; @@ -164,6 +167,8 @@ export function GraphWrapper({ const [pagingHasMore, setPagingHasMore] = useState(state.paging?.hasMore ?? false); const [isLoading, setIsLoading] = useState(state.loading); const [styleProps, setStyleProps] = useState(state.theming); + const [branchName, setBranchName] = useState(state.branchName); + const [lastFetched, setLastFetched] = useState(state.lastFetched); // account const [showAccount, setShowAccount] = useState(state.trialBanner); const [isAccessAllowed, setIsAccessAllowed] = useState(state.allowed ?? false); @@ -171,8 +176,6 @@ export function GraphWrapper({ state.selectedRepositoryVisibility === RepositoryVisibility.Private, ); const [subscription, setSubscription] = useState(state.subscription); - // repo selection UI - const [repoExpanded, setRepoExpanded] = useState(false); // search state const searchEl = useRef(null); const [searchQuery, setSearchQuery] = useState(undefined); @@ -247,11 +250,16 @@ export function GraphWrapper({ case DidChangeWorkingTreeNotificationType: setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); break; + case DidFetchNotificationType: + setLastFetched(state.lastFetched); + break; default: { setIsAccessAllowed(state.allowed ?? false); if (!themingChanged) { setStyleProps(state.theming); } + setBranchName(state.branchName); + setLastFetched(state.lastFetched); setColumns(state.columns); setRows(state.rows ?? []); setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); @@ -440,12 +448,6 @@ export function GraphWrapper({ setIsLoading(true); onSelectRepository?.(item); } - setRepoExpanded(false); - }; - - const handleToggleRepos = () => { - if (repo != null && repos.length <= 1) return; - setRepoExpanded(!repoExpanded); }; const handleMissingAvatars = (emails: GraphAvatars) => { @@ -556,7 +558,7 @@ export function GraphWrapper({ )} - + {isPro ? 'You have access to all GitLens and GitLens+ features on any repo.' : 'You have access to GitLens+ features on local & public repos, and all other GitLens features on any repo.'} @@ -699,22 +701,112 @@ export function GraphWrapper({ {renderAlertContent()} {isAccessAllowed && (
-
- 2)} - more={searchResults?.paging?.hasMore ?? false} - searching={searching} - value={searchQuery?.query ?? ''} - errorMessage={searchResultsError?.error ?? ''} - resultsHidden={searchResultsHidden} - resultsLoaded={searchResults != null} - onChange={e => handleSearchInput(e as CustomEvent)} - onNavigate={e => handleSearchNavigation(e as CustomEvent)} - onOpenInView={() => handleSearchOpenInView()} - /> +
+
+ {repos.length < 2 ? ( + + ) : ( + + + + {repos.length > 0 && + repos.map((item, index) => ( + handleSelectRepository(item)} + disabled={item.path === repo?.path} + key={`repo-actioncombo-item-${index}`} + > + + {item.formattedName} + + ))} + + + )} + {repo && ( + <> + + + + + {branchName} + + + + + + + Fetch{' '} + {lastFetched && (Last fetched {fromNow(new Date(lastFetched))})} + + + )} +
+
+ {renderAccountState()} + + + +
+
+
+
+ {/* + + + + + + + */} + 2)} + more={searchResults?.paging?.hasMore ?? false} + searching={searching} + value={searchQuery?.query ?? ''} + errorMessage={searchResultsError?.error ?? ''} + resultsHidden={searchResultsHidden} + resultsLoaded={searchResults != null} + onChange={e => handleSearchInput(e as CustomEvent)} + onNavigate={e => handleSearchNavigation(e as CustomEvent)} + onOpenInView={() => handleSearchOpenInView()} + /> +
)} @@ -784,67 +876,6 @@ export function GraphWrapper({ aria-hidden={!isAccessAllowed} >
-
- -
- {repos.length > 0 ? ( - repos.map((item, index) => ( - - )) - ) : ( - - None available - - )} -
-
{isAccessAllowed && rows.length > 0 && ( showing {rows.length} item{rows.length ? 's' : ''} @@ -856,16 +887,6 @@ export function GraphWrapper({ )}
-
- {renderAccountState()} - - - -
diff --git a/src/webviews/apps/plus/graph/graph.scss b/src/webviews/apps/plus/graph/graph.scss index 458cc95..405b921 100644 --- a/src/webviews/apps/plus/graph/graph.scss +++ b/src/webviews/apps/plus/graph/graph.scss @@ -3,6 +3,11 @@ @import '../../shared/glicons'; @import '../../../../../node_modules/@gitkraken/gitkraken-components/dist/styles.css'; +@mixin focusStyles() { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .vscode-high-contrast, .vscode-dark { --popover-bg: var(--color-background--lighten-15); @@ -57,9 +62,19 @@ a { } } +a, +button:not([disabled]), +[tabindex]:not([tabindex='-1']) { + &:focus { + @include focusStyles(); + } +} + .badge { + font-size: 1rem; font-weight: 700; text-transform: uppercase; + color: var(--color-foreground); &.is-help { cursor: help; @@ -78,7 +93,7 @@ a { &-popover { width: max-content; right: 0; - bottom: 100%; + top: 100%; } &:not(:hover) + &-popover { @@ -109,6 +124,7 @@ a { } &__details { + line-height: var(--actionbar-height); } &__loading { @@ -127,111 +143,83 @@ a { top: -2px; } } - - :focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } } -.actioncombo { - $block: &; - $block-expanded: #{$block}--expanded; - - --actioncombo-height: 2.2rem; - --actioncombo-items: 1; - --actioncombo-items-height: 2.4rem; - +.action-button { position: relative; - display: inline-flex; - flex-direction: row; - align-items: stretch; + appearance: none; + font-family: inherit; font-size: 1.2rem; - gap: 0.25rem; - height: var(--actioncombo-height); - line-height: var(--actioncombo-height); - - &__label { - appearance: none; - font-family: inherit; - background-color: var(--color-graph-actionbar-background); - border: none; - color: inherit; - padding: 0 0.75rem; - cursor: pointer; - height: var(--actioncombo-height); - line-height: var(--actioncombo-height); - border-radius: 3px; - - &[disabled] { - pointer-events: none; - cursor: default; - opacity: 1; - } + line-height: 2.2rem; + // background-color: var(--color-graph-actionbar-background); + background-color: transparent; + border: none; + color: inherit; + color: var(--color-foreground); + padding: 0 0.75rem; + cursor: pointer; + border-radius: 3px; + height: auto; - &:hover { - background-color: var(--color-graph-actionbar-selectedBackground); - } + &[disabled] { + pointer-events: none; + cursor: default; + opacity: 1; } - &__list { - position: absolute; - left: 0; - bottom: 100%; - display: flex; - flex-direction: column; - justify-content: stretch; - min-width: 100%; - width: max-content; - z-index: 10000; - background-color: var(--vscode-menu-background); - border: 1px solid var(--vscode-menu-border); + &:hover { + background-color: var(--color-graph-actionbar-selectedBackground); + color: var(--color-foreground); + text-decoration: none; } - &__label:not([aria-expanded='true']) + &__list { - display: none; + .codicon[class*='codicon-'] { + line-height: 2.2rem; + vertical-align: bottom; } - &__item { - appearance: none; - font-family: inherit; - border: none; - padding: 0 0.6rem; - cursor: pointer; - color: var(--vscode-menu-foreground); - background-color: var(--vscode-menu-background); - text-align: left; - display: flex; - align-items: center; - height: var(--actioncombo-items-height); - line-height: var(--actioncombo-items-height); - - &:hover { - color: var(--vscode-menu-selectionForeground); - background-color: var(--vscode-menu-selectionBackground); - } + &__icon, + &__icon.codicon[class*='codicon-'] { + margin-right: 0.4rem; + } + &__icon:not(.codicon) { + display: inline-block; + width: 1.6rem; + } - &[disabled] { - pointer-events: none; - cursor: default; - opacity: 0.5; - } + &__more, + &__more.codicon[class*='codicon-'] { + font-size: 1rem; + margin-left: 0.4rem; + margin-right: -0.35rem; + } - &[aria-selected='true'] { - opacity: 1; - color: var(--vscode-menu-selectionForeground); - background-color: var(--vscode-menu-background); - } + &__indicator { + position: absolute; + bottom: 0.2rem; + right: 1.5rem; + display: block; + width: 0.8rem; + height: 0.8rem; + border-radius: 100%; + background-color: var(--vscode-focusBorder); } - &__icon.codicon[class*='codicon-'] { - margin-right: 0.4rem; + small { + opacity: 0.6; } +} - &__icon:not(.codicon) { - display: inline-block; - margin-right: 0.4rem; - width: 16px; +.action-divider { + display: inline-block; + width: 0.1rem; + height: 2.2rem; + vertical-align: middle; + background-color: var(--vscode-titleBar-inactiveForeground); + opacity: 0.4; + margin: { + // left: 0.2rem; + right: 0.2rem; } } @@ -271,8 +259,7 @@ a { } &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; + @include focusStyles; } &[disabled] { @@ -431,128 +418,126 @@ a { letter-spacing: normal; } -.icon--head { - &::before { - // codicon-vm - font-family: codicon; - content: '\ea7a'; +.icon { + &--head { + &::before { + // codicon-vm + font-family: codicon; + content: '\ea7a'; + } } -} - -.icon--remote { - &::before { - // codicon-cloud - font-family: codicon; - content: '\ebaa'; + &--remote { + &::before { + // codicon-cloud + font-family: codicon; + content: '\ebaa'; + } } -} - -.icon--tag { - &::before { - // codicon-tag - font-family: codicon; - content: '\ea66'; + &--tag { + &::before { + // codicon-tag + font-family: codicon; + content: '\ea66'; + } } -} - -.icon--stash { - &::before { - // codicon-inbox - font-family: codicon; - content: '\eb09'; + &--stash { + &::before { + // codicon-inbox + font-family: codicon; + content: '\eb09'; + } } -} - -.icon--check { - &::before { - // codicon-check - font-family: codicon; - content: '\eab2'; + &--check { + &::before { + // codicon-check + font-family: codicon; + content: '\eab2'; + } } -} - -.icon--warning { - :before { - // codicon-vm - font-family: codicon; - content: '\ea6c'; + &--warning { + :before { + // codicon-vm + font-family: codicon; + content: '\ea6c'; + } + color: #de9b43; } - color: #de9b43; -} - -.icon--added { - &::before { - // codicon-add - font-family: codicon; - content: '\ea60'; + &--added { + &::before { + // codicon-add + font-family: codicon; + content: '\ea60'; + } } -} -.icon--modified { - &::before { - // codicon-edit - font-family: codicon; - content: '\ea73'; + &--modified { + &::before { + // codicon-edit + font-family: codicon; + content: '\ea73'; + } } -} -.icon--deleted { - &::before { - // codicon-dash - font-family: codicon; - content: '\eacc'; + &--deleted { + &::before { + // codicon-dash + font-family: codicon; + content: '\eacc'; + } } -} -.icon--renamed { - &::before { - // codicon-file - font-family: codicon; - content: '\eb60'; + &--renamed { + &::before { + // codicon-file + font-family: codicon; + content: '\eb60'; + } } -} -.icon--resolved { - &::before { - // codicon-pass-filled - font-family: codicon; - content: '\ebb3'; + &--resolved { + &::before { + // codicon-pass-filled + font-family: codicon; + content: '\ebb3'; + } } -} -.icon--hide { - &::before { - // codicon-eye-closed - font-family: codicon; - content: '\eae7'; + &--hide { + &::before { + // codicon-eye-closed + font-family: codicon; + content: '\eae7'; + } } -} -.icon--show { - &::before { - // codicon-eye - font-family: codicon; - content: '\ea70'; + &--show { + &::before { + // codicon-eye + font-family: codicon; + content: '\ea70'; + } } -} - -.icon--pull-request { - &::before { - // codicon-git-pull-request - font-family: codicon; - content: '\ea64'; + &--pull-request { + &::before { + // codicon-git-pull-request + font-family: codicon; + content: '\ea64'; + } } } .titlebar { background: var(--vscode-titleBar-inactiveBackground); color: var(--vscode-titleBar-inactiveForeground); - height: 3.6rem; + // height: 3.6rem; padding: { left: 0.8rem; right: 0.8rem; + top: 0.6rem; + bottom: 0.6rem; } font-size: 1.3rem; + flex-wrap: wrap; &, + &__row, &__group { display: flex; flex-direction: row; - justify-content: space-between; align-items: center; gap: 0.5rem; @@ -561,8 +546,21 @@ a { } } + &, + &__row { + justify-content: space-between; + } + + &__row { + flex: 0 0 100%; + } + &__group { flex: auto 1 1; + + &--fixed { + flex: none; + } } } @@ -678,8 +676,7 @@ a { } &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; + @include focusStyles; } &.active, @@ -797,42 +794,43 @@ a { } } -.tooltip.in { - opacity: 1; -} -.tooltip.top .tooltip-arrow { - border-top-color: var(--color-hover-border); -} -.tooltip.top-left .tooltip-arrow { - border-top-color: var(--color-hover-border); -} -.tooltip.top-right .tooltip-arrow { - border-top-color: var(--color-hover-border); -} -.tooltip.right .tooltip-arrow { - border-right-color: var(--color-hover-border); -} -.tooltip.left .tooltip-arrow { - border-left-color: var(--color-hover-border); -} -.tooltip.bottom .tooltip-arrow { - border-bottom-color: var(--color-hover-border); -} -.tooltip.bottom-left .tooltip-arrow { - border-bottom-color: var(--color-hover-border); -} -.tooltip.bottom-right .tooltip-arrow { - border-bottom-color: var(--color-hover-border); -} .tooltip { font-size: var(--vscode-font-size); font-family: var(--vscode-font-family); -} -.tooltip-inner { - font-size: 1.2rem; - padding: 0.3rem 0.6rem; - color: var(--color-hover-foreground); - background-color: var(--color-hover-background); - border: 0.1rem solid var(--color-hover-border); - border-radius: 0; + + &.in { + opacity: 1; + } + &.top .tooltip-arrow { + border-top-color: var(--color-hover-border); + } + &.top-left .tooltip-arrow { + border-top-color: var(--color-hover-border); + } + &.top-right .tooltip-arrow { + border-top-color: var(--color-hover-border); + } + &.right .tooltip-arrow { + border-right-color: var(--color-hover-border); + } + &.left .tooltip-arrow { + border-left-color: var(--color-hover-border); + } + &.bottom .tooltip-arrow { + border-bottom-color: var(--color-hover-border); + } + &.bottom-left .tooltip-arrow { + border-bottom-color: var(--color-hover-border); + } + &.bottom-right .tooltip-arrow { + border-bottom-color: var(--color-hover-border); + } + &-inner { + font-size: 1.2rem; + padding: 0.3rem 0.6rem; + color: var(--color-hover-foreground); + background-color: var(--color-hover-background); + border: 0.1rem solid var(--color-hover-border); + border-radius: 0; + } } diff --git a/src/webviews/apps/plus/graph/graph.tsx b/src/webviews/apps/plus/graph/graph.tsx index 3b2a9cf..e82cb32 100644 --- a/src/webviews/apps/plus/graph/graph.tsx +++ b/src/webviews/apps/plus/graph/graph.tsx @@ -27,6 +27,7 @@ import { DidChangeSubscriptionNotificationType, DidChangeWorkingTreeNotificationType, DidEnsureRowNotificationType, + DidFetchNotificationType, DidSearchNotificationType, DismissBannerCommandType, DoubleClickedRefCommandType, @@ -129,6 +130,13 @@ export class GraphApp extends App { }); break; + case DidFetchNotificationType.method: + onIpc(DidFetchNotificationType, msg, (params, type) => { + this.state.lastFetched = params.lastFetched; + this.setState(this.state, type); + }); + break; + case DidChangeAvatarsNotificationType.method: onIpc(DidChangeAvatarsNotificationType, msg, (params, type) => { this.state.avatars = params.avatars; diff --git a/src/webviews/apps/shared/components/menu/index.ts b/src/webviews/apps/shared/components/menu/index.ts new file mode 100644 index 0000000..73f97d6 --- /dev/null +++ b/src/webviews/apps/shared/components/menu/index.ts @@ -0,0 +1,3 @@ +export * from './menu-list'; +export * from './menu-item'; +export * from './menu-label'; diff --git a/src/webviews/apps/shared/components/menu/menu-item.ts b/src/webviews/apps/shared/components/menu/menu-item.ts new file mode 100644 index 0000000..9641ae8 --- /dev/null +++ b/src/webviews/apps/shared/components/menu/menu-item.ts @@ -0,0 +1,49 @@ +import { attr, css, customElement, FASTElement, html } from '@microsoft/fast-element'; +import { elementBase } from '../styles/base'; + +const template = html` + +`; + +const styles = css` + ${elementBase} + + :host { + font-family: inherit; + border: none; + padding: 0 0.6rem; + cursor: pointer; + color: var(--vscode-menu-foreground); + background-color: var(--vscode-menu-background); + text-align: left; + display: flex; + align-items: center; + height: auto; + line-height: 2.2rem; + } + + :host(:hover) { + color: var(--vscode-menu-selectionForeground); + background-color: var(--vscode-menu-selectionBackground); + } + + :host([disabled]) { + pointer-events: none; + cursor: default; + opacity: 0.5; + } + + :host([aria-selected='true']) { + opacity: 1; + color: var(--vscode-menu-selectionForeground); + background-color: var(--vscode-menu-background); + } +`; + +@customElement({ name: 'menu-item', template: template, styles: styles }) +export class MenuItem extends FASTElement { + @attr({ mode: 'boolean' }) + disabled = false; +} diff --git a/src/webviews/apps/shared/components/menu/menu-label.ts b/src/webviews/apps/shared/components/menu/menu-label.ts new file mode 100644 index 0000000..9e8e11e --- /dev/null +++ b/src/webviews/apps/shared/components/menu/menu-label.ts @@ -0,0 +1,24 @@ +import { css, customElement, FASTElement, html } from '@microsoft/fast-element'; +import { elementBase } from '../styles/base'; + +const template = html` + +`; + +const styles = css` + ${elementBase} + + :host { + text-transform: uppercase; + font-size: 0.84em; + line-height: 2.2rem; + padding-left: 0.6rem; + padding-right: 0.6rem; + margin: 0px; + } +`; + +@customElement({ name: 'menu-label', template: template, styles: styles }) +export class MenuLabel extends FASTElement {} diff --git a/src/webviews/apps/shared/components/menu/menu-list.ts b/src/webviews/apps/shared/components/menu/menu-list.ts new file mode 100644 index 0000000..57243a9 --- /dev/null +++ b/src/webviews/apps/shared/components/menu/menu-list.ts @@ -0,0 +1,21 @@ +import { css, customElement, FASTElement, html } from '@microsoft/fast-element'; +import { elementBase } from '../styles/base'; + +const template = html` + +`; + +const styles = css` + ${elementBase} + + :host { + width: max-content; + background-color: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + } +`; + +@customElement({ name: 'menu-list', template: template, styles: styles }) +export class MenuList extends FASTElement {} diff --git a/src/webviews/apps/shared/components/menu/react.tsx b/src/webviews/apps/shared/components/menu/react.tsx new file mode 100644 index 0000000..b46f782 --- /dev/null +++ b/src/webviews/apps/shared/components/menu/react.tsx @@ -0,0 +1,6 @@ +import { reactWrapper } from '../helpers/react-wrapper'; +import { MenuItem as MenuItemComponent, MenuLabel as MenuLabelComponent, MenuList as MenuListComponent } from './index'; + +export const MenuItem = reactWrapper(MenuItemComponent); +export const MenuLabel = reactWrapper(MenuLabelComponent); +export const MenuList = reactWrapper(MenuListComponent); diff --git a/src/webviews/apps/shared/components/overlays/pop-menu/index.ts b/src/webviews/apps/shared/components/overlays/pop-menu/index.ts new file mode 100644 index 0000000..136afc6 --- /dev/null +++ b/src/webviews/apps/shared/components/overlays/pop-menu/index.ts @@ -0,0 +1 @@ +export * from './pop-menu'; diff --git a/src/webviews/apps/shared/components/overlays/pop-menu/pop-menu.ts b/src/webviews/apps/shared/components/overlays/pop-menu/pop-menu.ts new file mode 100644 index 0000000..1a731f2 --- /dev/null +++ b/src/webviews/apps/shared/components/overlays/pop-menu/pop-menu.ts @@ -0,0 +1,120 @@ +import { attr, css, customElement, FASTElement, html, observable, slotted, volatile } from '@microsoft/fast-element'; +import { hasNodes, nodeTypeFilter } from '../../helpers/slots'; +import { elementBase } from '../../styles/base'; + +const template = html` + +`; + +const styles = css` + ${elementBase} + + :host { + position: relative; + } + + slot[name='content']::slotted(*) { + position: absolute; + left: 0; + top: 100%; + z-index: 10000; + } + + :host(:not([open])) slot[name='content']::slotted(*) { + display: none; + } +`; + +@customElement({ name: 'pop-menu', template: template, styles: styles }) +export class PopMenu extends FASTElement { + @attr({ mode: 'boolean' }) + open = false; + + @observable + triggerNodes?: HTMLElement[]; + + @observable + contentNodes?: HTMLElement[]; + + @volatile + get triggerNode() { + if (!hasNodes(this.triggerNodes)) { + return; + } + + return this.triggerNodes![0]; + } + + @volatile + get contentNode() { + if (!hasNodes(this.contentNodes)) { + return; + } + + return this.contentNodes![0]; + } + + isTrackingOutside = false; + + override connectedCallback() { + super.connectedCallback(); + this.updateToggle(); + this.addEventListener('click', this.handleToggle.bind(this), false); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('click', this.handleToggle.bind(this), false); + this.disposeTrackOutside(); + } + + handleToggle() { + this.open = !this.open; + this.updateToggle(); + } + + updateToggle() { + if (this.triggerNode != null) { + this.triggerNode.ariaExpanded = this.open.toString(); + } + if (this.open) { + if (this.contentNode != null) { + window.requestAnimationFrame(() => { + this.contentNode?.focus(); + }); + } + this.trackOutside(); + } + } + + handleDocumentEvent(e: MouseEvent | FocusEvent) { + if (this.open === false) return; + + const composedPath = e.composedPath(); + if (!composedPath.includes(this)) { + this.open = false; + this.disposeTrackOutside(); + } + } + + trackOutside() { + if (this.isTrackingOutside || !this.open) return; + + this.isTrackingOutside = true; + const boundHandleDocumentEvent = this.handleDocumentEvent.bind(this); + document.addEventListener('click', boundHandleDocumentEvent, false); + document.addEventListener('focusin', boundHandleDocumentEvent, false); + } + + disposeTrackOutside() { + if (!this.isTrackingOutside) return; + + this.isTrackingOutside = false; + const boundHandleDocumentEvent = this.handleDocumentEvent.bind(this); + document.removeEventListener('click', boundHandleDocumentEvent, false); + window.removeEventListener('focusin', boundHandleDocumentEvent, false); + } +} diff --git a/src/webviews/apps/shared/components/overlays/pop-menu/react.tsx b/src/webviews/apps/shared/components/overlays/pop-menu/react.tsx new file mode 100644 index 0000000..935a56f --- /dev/null +++ b/src/webviews/apps/shared/components/overlays/pop-menu/react.tsx @@ -0,0 +1,4 @@ +import { reactWrapper } from '../../helpers/react-wrapper'; +import { PopMenu as PopMenuComponent } from './index'; + +export const PopMenu = reactWrapper(PopMenuComponent);