From e729ed337d845f67deda10ebddadcaa826883e55 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Thu, 12 Dec 2024 04:16:28 -0500 Subject: [PATCH] Redesigns Home account footer into new header bar - Adds popover for account information - Adds popover for integration connection and status --- src/constants.integrations.ts | 41 +- src/plus/gk/account/__debug__accountDebug.ts | 5 +- src/plus/gk/account/subscriptionService.ts | 93 +-- .../integrations/authentication/models.ts | 8 +- src/plus/integrations/integrationService.ts | 18 +- .../apps/home/components/onboarding.ts | 73 ++- src/webviews/apps/home/home.css.ts | 25 +- src/webviews/apps/home/home.ts | 23 +- src/webviews/apps/home/stateProvider.ts | 1 + .../plus/shared/components/account-chip.ts | 528 ++++++++++++++++++ .../apps/plus/shared/components/chipStyles.ts | 53 ++ .../shared/components/home-account-content.ts | 449 --------------- .../plus/shared/components/home-header.ts | 70 +++ .../shared/components/integrations-chip.ts | 261 +++++++++ src/webviews/home/homeWebview.ts | 92 ++- src/webviews/home/protocol.ts | 7 + 16 files changed, 1161 insertions(+), 586 deletions(-) create mode 100644 src/webviews/apps/plus/shared/components/account-chip.ts create mode 100644 src/webviews/apps/plus/shared/components/chipStyles.ts delete mode 100644 src/webviews/apps/plus/shared/components/home-account-content.ts create mode 100644 src/webviews/apps/plus/shared/components/home-header.ts create mode 100644 src/webviews/apps/plus/shared/components/integrations-chip.ts diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index b0bc4819fd180..6b64aea480bce 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -17,11 +17,44 @@ export enum IssueIntegrationId { export type IntegrationId = HostingIntegrationId | IssueIntegrationId | SelfHostedIntegrationId; -export const supportedCloudIntegrationIds = [IssueIntegrationId.Jira]; -export const supportedCloudIntegrationIdsExperimental = [ - IssueIntegrationId.Jira, +export const supportedOrderedCloudIssueIntegrationIds = [IssueIntegrationId.Jira]; +export const supportedOrderedCloudIntegrationIds = [ HostingIntegrationId.GitHub, HostingIntegrationId.GitLab, + IssueIntegrationId.Jira, ]; -export type SupportedCloudIntegrationIds = (typeof supportedCloudIntegrationIdsExperimental)[number]; +export type SupportedCloudIntegrationIds = (typeof supportedOrderedCloudIntegrationIds)[number]; + +export function isSupportedCloudIntegrationId(id: IntegrationId): id is SupportedCloudIntegrationIds { + return supportedOrderedCloudIntegrationIds.includes(id as SupportedCloudIntegrationIds); +} + +export type IntegrationFeatures = 'prs' | 'issues'; + +export interface IntegrationDescriptor { + id: SupportedCloudIntegrationIds; + name: string; + icon: string; + supports: IntegrationFeatures[]; +} +export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ + { + id: HostingIntegrationId.GitHub, + name: 'GitHub', + icon: 'gl-provider-github', + supports: ['prs', 'issues'], + }, + { + id: HostingIntegrationId.GitLab, + name: 'GitLab', + icon: 'gl-provider-gitlab', + supports: ['prs', 'issues'], + }, + { + id: IssueIntegrationId.Jira, + name: 'Jira', + icon: 'gl-provider-jira', + supports: ['issues'], + }, +]; diff --git a/src/plus/gk/account/__debug__accountDebug.ts b/src/plus/gk/account/__debug__accountDebug.ts index 837700af8bd40..04a60567bc5f3 100644 --- a/src/plus/gk/account/__debug__accountDebug.ts +++ b/src/plus/gk/account/__debug__accountDebug.ts @@ -258,7 +258,7 @@ class AccountDebug { this.service.restoreFeaturePreviews(); this.service.restoreSession(); - this.service.changeSubscription(this.service.getStoredSubscription(), { store: false }); + this.service.changeSubscription(this.service.getStoredSubscription(), undefined, { store: false }); } private async startSimulation(pick: SimulateQuickPickItem | undefined): Promise { @@ -287,6 +287,7 @@ class AccountDebug { state === SubscriptionState.Community ? undefined : getPreviewSubscription(state === SubscriptionState.ProPreviewExpired ? 0 : 3), + undefined, { store: false }, ); @@ -341,7 +342,7 @@ class AccountDebug { activeOrganizationId, ); - this.service.changeSubscription({ ...subscription, ...simulatedSubscription }, { store: false }); + this.service.changeSubscription({ ...subscription, ...simulatedSubscription }, undefined, { store: false }); return false; } diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts index b55dbd0a32bd9..4ba7588d2c7fb 100644 --- a/src/plus/gk/account/subscriptionService.ts +++ b/src/plus/gk/account/subscriptionService.ts @@ -135,7 +135,7 @@ export class SubscriptionService implements Disposable { this.updateContext(); } }), - container.uri.onDidReceiveSubscriptionUpdatedUri(this.checkUpdatedSubscription, this), + container.uri.onDidReceiveSubscriptionUpdatedUri(() => this.checkUpdatedSubscription(undefined), this), container.uri.onDidReceiveLoginUri(this.onLoginUri, this), ); @@ -152,8 +152,8 @@ export class SubscriptionService implements Disposable { } } - this.changeSubscription(subscription, { silent: true }); - setTimeout(() => void this.ensureSession(false), 10000); + this.changeSubscription(subscription, undefined, { silent: true }); + setTimeout(() => void this.ensureSession(false, undefined), 10000); } dispose(): void { @@ -341,7 +341,7 @@ export class SubscriptionService implements Disposable { registerCommand(GlCommand.PlusLogin, (src?: Source) => this.loginOrSignUp(false, src)), registerCommand(GlCommand.PlusSignUp, (src?: Source) => this.loginOrSignUp(true, src)), registerCommand(GlCommand.PlusLogout, (src?: Source) => this.logout(src)), - registerCommand(GlCommand.GKSwitchOrganization, () => this.switchOrganization()), + registerCommand(GlCommand.GKSwitchOrganization, (src?: Source) => this.switchOrganization(src)), registerCommand(GlCommand.PlusManage, (src?: Source) => this.manage(src)), registerCommand(GlCommand.PlusShowPlans, (src?: Source) => this.showPlans(src)), @@ -362,11 +362,11 @@ export class SubscriptionService implements Disposable { } async getAuthenticationSession(createIfNeeded: boolean = false): Promise { - return this.ensureSession(createIfNeeded); + return this.ensureSession(createIfNeeded, undefined); } async getSubscription(cached = false): Promise { - const promise = this.ensureSession(false); + const promise = this.ensureSession(false, undefined); if (!cached) { void (await promise); } @@ -465,7 +465,7 @@ export class SubscriptionService implements Disposable { } private async showPlanMessage(source: Source | undefined) { - if (!(await this.ensureSession(false))) return; + if (!(await this.ensureSession(false, source))) return; const { account, plan: { actual, effective }, @@ -563,7 +563,7 @@ export class SubscriptionService implements Disposable { this.container.telemetry.sendEvent('subscription/action', { action: 'sign-in' }, source); } - const session = await this.ensureSession(false); + const session = await this.ensureSession(false, source); if (session != null) { await this.logout(source); } @@ -581,7 +581,7 @@ export class SubscriptionService implements Disposable { await this.container.accountAuthentication.abort(); void this.showAccountView(); - const session = await this.ensureSession(true, { + const session = await this.ensureSession(true, options?.source, { signIn: options?.signIn, signUp: options?.signUp, context: options?.context, @@ -599,10 +599,10 @@ export class SubscriptionService implements Disposable { this.container.telemetry.sendEvent('subscription/action', { action: 'sign-out' }, source); } - return this.logoutCore(); + return this.logoutCore(source); } - private async logoutCore(): Promise { + private async logoutCore(source: Source | undefined): Promise { this.connection.resetRequestExceptionCount(); this._lastValidatedDate = undefined; if (this._validationTimer != null) { @@ -621,7 +621,7 @@ export class SubscriptionService implements Disposable { void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); } - this.changeSubscription(getCommunitySubscription(this._subscription)); + this.changeSubscription(getCommunitySubscription(this._subscription), source); } @log() @@ -650,7 +650,7 @@ export class SubscriptionService implements Disposable { this.container.telemetry.sendEvent('subscription/action', { action: 'reactivate' }, source); } - const session = await this.ensureSession(false); + const session = await this.ensureSession(false, source); if (session == null) return; try { @@ -693,7 +693,7 @@ export class SubscriptionService implements Disposable { // Trial was reactivated. Do a check-in to update, and show a message if successful. try { - await this.checkInAndValidate(session, { force: true }); + await this.checkInAndValidate(session, source, { force: true }); if (isSubscriptionTrial(this._subscription)) { const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); @@ -731,7 +731,7 @@ export class SubscriptionService implements Disposable { } void this.showAccountView(true); - const session = await this.ensureSession(false); + const session = await this.ensureSession(false, source); if (session == null) return false; try { @@ -846,7 +846,7 @@ export class SubscriptionService implements Disposable { const days = proPreviewLengthInDays; const subscription = getPreviewSubscription(days, this._subscription); - this.changeSubscription(subscription); + this.changeSubscription(subscription, source); setTimeout(async () => { const confirm: MessageItem = { title: 'Continue' }; @@ -878,9 +878,9 @@ export class SubscriptionService implements Disposable { if (this._subscription.account != null) { // Do a pre-check-in to see if we've already upgraded to a paid plan. try { - const session = await this.ensureSession(false); + const session = await this.ensureSession(false, source); if (session != null) { - if ((await this.checkUpdatedSubscription()) === SubscriptionState.Paid) { + if ((await this.checkUpdatedSubscription(source)) === SubscriptionState.Paid) { return; } } @@ -958,23 +958,23 @@ export class SubscriptionService implements Disposable { const refresh = await Promise.race(completionPromises); if (refresh) { - void this.checkUpdatedSubscription(); + void this.checkUpdatedSubscription(source); } } @gate(o => `${o?.force ?? false}`) @log() - async validate(options?: { force?: boolean }, _source?: Source | undefined): Promise { + async validate(options?: { force?: boolean }, source?: Source | undefined): Promise { const scope = getLogScope(); - const session = await this.ensureSession(false); + const session = await this.ensureSession(false, source); if (session == null) { - this.changeSubscription(this._subscription); + this.changeSubscription(this._subscription, source); return; } try { - await this.checkInAndValidate(session, options); + await this.checkInAndValidate(session, source, options); } catch (ex) { Logger.error(ex, scope); debugger; @@ -986,6 +986,7 @@ export class SubscriptionService implements Disposable { @debug({ args: { 0: s => s?.account?.label } }) private async checkInAndValidate( session: AuthenticationSession, + source: Source | undefined, options?: { force?: boolean; showSlowProgress?: boolean; organizationId?: string }, ): Promise { const scope = getLogScope(); @@ -1001,7 +1002,7 @@ export class SubscriptionService implements Disposable { return; } - const validating = this.checkInAndValidateCore(session, options?.organizationId); + const validating = this.checkInAndValidateCore(session, source, options?.organizationId); if (!options?.showSlowProgress) return validating; // Show progress if we are waiting too long @@ -1020,6 +1021,7 @@ export class SubscriptionService implements Disposable { @debug({ args: { 0: s => s?.account?.label } }) private async checkInAndValidateCore( session: AuthenticationSession, + source: Source | undefined, organizationId?: string, ): Promise { const scope = getLogScope(); @@ -1059,7 +1061,7 @@ export class SubscriptionService implements Disposable { this._getCheckInData = () => Promise.resolve(data); this.storeCheckInData(data); - await this.validateAndUpdateSubscriptions(data, session); + await this.validateAndUpdateSubscriptions(data, session, source); return data; } catch (ex) { this._getCheckInData = () => Promise.resolve(undefined); @@ -1068,7 +1070,7 @@ export class SubscriptionService implements Disposable { debugger; // If we cannot check in, validate stored subscription - this.changeSubscription(this._subscription); + this.changeSubscription(this._subscription, source); if (ex instanceof AccountValidationError) throw ex; throw new AccountValidationError('Unable to validate account', ex); @@ -1086,7 +1088,7 @@ export class SubscriptionService implements Disposable { this._validationTimer = setInterval( () => { if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { - void this.ensureSession(false, { force: true }); + void this.ensureSession(false, undefined, { force: true }); } }, 6 * 60 * 60 * 1000, @@ -1115,7 +1117,7 @@ export class SubscriptionService implements Disposable { if (session == null) return undefined; try { - return await this.checkInAndValidate(session, { force: true }); + return await this.checkInAndValidate(session, undefined, { force: true }); } catch (ex) { Logger.error(ex, scope); return undefined; @@ -1126,7 +1128,11 @@ export class SubscriptionService implements Disposable { } @debug() - private async validateAndUpdateSubscriptions(data: GKCheckInResponse, session: AuthenticationSession) { + private async validateAndUpdateSubscriptions( + data: GKCheckInResponse, + session: AuthenticationSession, + source: Source | undefined, + ): Promise { const scope = getLogScope(); let organizations: Organization[]; try { @@ -1154,6 +1160,7 @@ export class SubscriptionService implements Disposable { ...this._subscription, ...subscription, }, + source, { store: true }, ); } @@ -1165,6 +1172,7 @@ export class SubscriptionService implements Disposable { @debug() private async ensureSession( createIfNeeded: boolean, + source: Source | undefined, options?: { force?: boolean; signUp?: boolean; @@ -1180,7 +1188,7 @@ export class SubscriptionService implements Disposable { if (this._session === null && !createIfNeeded) return undefined; if (this._sessionPromise === undefined) { - this._sessionPromise = this.getOrCreateSession(createIfNeeded, { + this._sessionPromise = this.getOrCreateSession(createIfNeeded, source, { signUp: options?.signUp, signIn: options?.signIn, context: options?.context, @@ -1205,6 +1213,7 @@ export class SubscriptionService implements Disposable { @debug() private async getOrCreateSession( createIfNeeded: boolean, + source: Source | undefined, options?: { signUp?: boolean; signIn?: { code: string; state?: string }; context?: TrackingContext }, ): Promise { const scope = getLogScope(); @@ -1226,7 +1235,7 @@ export class SubscriptionService implements Disposable { if (ex instanceof Error && ex.message.includes('User did not consent')) { setLogScopeExit(scope, ' \u2022 User declined authentication'); - await this.logoutCore(); + await this.logoutCore(source); return null; } @@ -1235,12 +1244,12 @@ export class SubscriptionService implements Disposable { if (session == null) { setLogScopeExit(scope, ' \u2022 No valid session was found'); - await this.logoutCore(); + await this.logoutCore(source); return session ?? null; } try { - await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); + await this.checkInAndValidate(session, source, { showSlowProgress: createIfNeeded, force: createIfNeeded }); } catch (ex) { Logger.error(ex, scope); debugger; @@ -1272,7 +1281,7 @@ export class SubscriptionService implements Disposable { ex.statusCode >= 400 ) { session = null; - await this.logoutCore(); + await this.logoutCore(source); if (createIfNeeded) { const unauthorized = ex.statusCode === 401; @@ -1316,6 +1325,7 @@ export class SubscriptionService implements Disposable { @debug() private changeSubscription( subscription: Optional | undefined, + source: Source | undefined, options?: { silent?: boolean; store?: boolean }, ): void { if (subscription == null) { @@ -1389,7 +1399,11 @@ export class SubscriptionService implements Disposable { ...(!matches ? flattenSubscription(previous, 'previous') : {}), }; - this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); + this.container.telemetry.sendEvent( + previous == null ? 'subscription' : 'subscription/changed', + data, + source, + ); }); if (options?.store !== false) { @@ -1584,7 +1598,7 @@ export class SubscriptionService implements Disposable { this._statusBarSubscription.show(); } - async switchOrganization(): Promise { + async switchOrganization(source: Source | undefined): Promise { const scope = getLogScope(); if (this._session == null) return; @@ -1619,7 +1633,7 @@ export class SubscriptionService implements Disposable { return; } - await this.checkInAndValidate(this._session, { force: true, organizationId: pick.org.id }); + await this.checkInAndValidate(this._session, source, { force: true, organizationId: pick.org.id }); const checkInData = await this._getCheckInData(); if (checkInData == null) return; @@ -1634,6 +1648,7 @@ export class SubscriptionService implements Disposable { ...this._subscription, ...organizationSubscription, }, + source, { store: true }, ); } @@ -1663,10 +1678,10 @@ export class SubscriptionService implements Disposable { void this.loginWithCode({ code: code, state: state ?? undefined }, { source: 'deeplink' }); } - async checkUpdatedSubscription(): Promise { + async checkUpdatedSubscription(source: Source | undefined): Promise { if (this._session == null) return undefined; const oldSubscriptionState = this._subscription.state; - await this.checkInAndValidate(this._session, { force: true }); + await this.checkInAndValidate(this._session, source, { force: true }); if (oldSubscriptionState !== this._subscription.state) { void this.showPlanMessage({ source: 'subscription' }); } diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index f5c80c0f34fb9..9c328ea0d7c99 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -4,8 +4,8 @@ import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId, - supportedCloudIntegrationIds, - supportedCloudIntegrationIdsExperimental, + supportedOrderedCloudIntegrationIds, + supportedOrderedCloudIssueIntegrationIds, } from '../../../constants.integrations'; import { configuration } from '../../../system/vscode/configuration'; @@ -40,8 +40,8 @@ export const CloudIntegrationAuthenticationUriPathPrefix = 'did-authenticate-clo export function getSupportedCloudIntegrationIds(): SupportedCloudIntegrationIds[] { return configuration.get('cloudIntegrations.enabled', undefined, true) - ? supportedCloudIntegrationIdsExperimental - : supportedCloudIntegrationIds; + ? supportedOrderedCloudIntegrationIds + : supportedOrderedCloudIssueIntegrationIds; } export function isSupportedCloudIntegrationId(id: string): id is SupportedCloudIntegrationIds { diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index bc96903d59c5b..d1ed46b05b93a 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -14,7 +14,7 @@ import type { RemoteProvider, RemoteProviderId } from '../../git/remotes/remoteP import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; import { promisifyDeferred, take } from '../../system/event'; -import { filterMap, flatten, join } from '../../system/iterable'; +import { filter, filterMap, flatten, join } from '../../system/iterable'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; import { configuration } from '../../system/vscode/configuration'; @@ -492,6 +492,16 @@ export class IntegrationService implements Disposable { return integration; } + getLoaded(): Iterable; + getLoaded(type: 'issues'): Iterable; + getLoaded(type: 'hosting'): Iterable; + @log() + getLoaded(type?: IntegrationType): Iterable { + if (type == null) return this._integrations.values(); + + return filter(this._integrations.values(), i => i.type === type); + } + private _providersApi: Promise | undefined; private async getProvidersApi() { if (this._providersApi == null) { @@ -550,12 +560,6 @@ export class IntegrationService implements Disposable { } } - getConnected(type: 'issues'): IssueIntegration[]; - getConnected(type: 'hosting'): HostingIntegration[]; - getConnected(type: IntegrationType): Integration[] { - return [...this._integrations.values()].filter(p => p.maybeConnected && p.type === type); - } - @log({ args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, }) diff --git a/src/webviews/apps/home/components/onboarding.ts b/src/webviews/apps/home/components/onboarding.ts index 11360fcd969f3..484f90a379902 100644 --- a/src/webviews/apps/home/components/onboarding.ts +++ b/src/webviews/apps/home/components/onboarding.ts @@ -1,7 +1,6 @@ import { consume } from '@lit/context'; -import { html, LitElement } from 'lit'; -import { customElement, query, state } from 'lit/decorators.js'; -import { GlCommand } from '../../../../constants.commands'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { createCommandLink } from '../../../../system/commands'; import type { State } from '../../../home/protocol'; import { DismissWalkthroughSection } from '../../../home/protocol'; @@ -25,6 +24,9 @@ export class GlOnboarding extends LitElement { @state() private _ipc!: HostIpc; + @property({ type: Boolean }) + private slim = false; + @query('#open-walkthrough') private _openWalkthroughButton!: GlButton; @@ -34,33 +36,37 @@ export class GlOnboarding extends LitElement { } return html` -
this.onFallthroughClick(e)}> -
- GitLens Walkthrough - (${this._state.walkthroughProgress.doneCount}/${this._state.walkthroughProgress.allCount}) - -
- -
+ + +
+ ${!this.slim + ? html` +
+ GitLens Walkthrough + (${this._state.walkthroughProgress.doneCount}/${this._state + .walkthroughProgress.allCount}) + +
+ ` + : nothing} + +
+
+
`; } @@ -69,11 +75,4 @@ export class GlOnboarding extends LitElement { this.requestUpdate(); this._ipc.sendCommand(DismissWalkthroughSection); } - - private onFallthroughClick(e: MouseEvent) { - if ((e.target as HTMLElement)?.closest('gl-button')) { - return; - } - this._openWalkthroughButton.click(); - } } diff --git a/src/webviews/apps/home/home.css.ts b/src/webviews/apps/home/home.css.ts index 1063b4e33884d..26e9ff55d627c 100644 --- a/src/webviews/apps/home/home.css.ts +++ b/src/webviews/apps/home/home.css.ts @@ -62,10 +62,16 @@ export const homeStyles = css` } .home__aux, - .home__footer { + .home__header { flex: none; } + .home__header { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border); + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border); + padding: 0.4rem; + } + .home__aux:has(gl-promo-banner:not([has-promo]):only-child) { display: none; } @@ -82,8 +88,8 @@ export const homeStyles = css` margin-block-end: 0.8rem; } - gl-home-account-content { - margin-bottom: 0; + gl-home-header { + margin: 0; } gl-repo-alerts:not([has-alerts]) { @@ -306,13 +312,19 @@ export const navListStyles = css` `; export const walkthroughProgressStyles = css` + a, + a:hover, + a:focus, + a:active { + text-decoration: none; + } + .walkthrough-progress { display: flex; flex-direction: column; gap: 2px; - padding: 4px 8px 6px; - margin-inline: -8px; - margin-bottom: 16px; + padding: 2px 4px 4px; + margin-top: 4px; align-items: stretch; cursor: pointer; border-radius: 4px; @@ -326,6 +338,7 @@ export const walkthroughProgressStyles = css` display: flex; justify-content: space-between; align-items: center; + color: var(--color-foreground--85); } .walkthrough-progress__button { --button-padding: 1px 2px 0px 2px; diff --git a/src/webviews/apps/home/home.ts b/src/webviews/apps/home/home.ts index 8e44ae00bc41e..a2ec97bbbc448 100644 --- a/src/webviews/apps/home/home.ts +++ b/src/webviews/apps/home/home.ts @@ -7,20 +7,19 @@ import { when } from 'lit/directives/when.js'; import type { State } from '../../home/protocol'; import { DidFocusAccount } from '../../home/protocol'; import { OverviewState, overviewStateContext } from '../plus/home/components/overviewState'; -import type { GLHomeAccountContent } from '../plus/shared/components/home-account-content'; +import type { GLHomeHeader } from '../plus/shared/components/home-header'; import { GlApp } from '../shared/app'; import { scrollableBase } from '../shared/components/styles/lit/base.css'; import type { Disposable } from '../shared/events'; import type { HostIpc } from '../shared/ipc'; import { homeBaseStyles, homeStyles } from './home.css'; import { HomeStateProvider } from './stateProvider'; -import '../plus/shared/components/home-account-content'; +import '../plus/shared/components/home-header'; import '../plus/home/components/active-work'; import '../plus/home/components/launchpad'; import '../plus/home/components/overview'; import './components/feature-nav'; import './components/integration-banner'; -import './components/onboarding'; import './components/preview-banner'; import './components/promo-banner'; import './components/repo-alerts'; @@ -33,8 +32,8 @@ export class GlHomeApp extends GlApp { @provide({ context: overviewStateContext }) private _overviewState!: OverviewState; - @query('#account-content') - private accountContentEl!: GLHomeAccountContent; + @query('gl-home-header') + private _header!: GLHomeHeader; private badgeSource = { source: 'home', detail: 'badge' }; @@ -51,7 +50,7 @@ export class GlHomeApp extends GlApp { this._ipc.onReceiveMessage(msg => { switch (true) { case DidFocusAccount.is(msg): - this.accountContentEl.show(); + this._header.show(); break; } }), @@ -67,18 +66,14 @@ export class GlHomeApp extends GlApp { override render() { return html`
- + + ${when(!this.state.previewEnabled, () => html``)}
- ${when( this.state?.previewEnabled === true, () => html` - @@ -86,10 +81,6 @@ export class GlHomeApp extends GlApp { () => html``, )}
- -
- -
`; } diff --git a/src/webviews/apps/home/stateProvider.ts b/src/webviews/apps/home/stateProvider.ts index c08f0042b70bc..7f9428695e47a 100644 --- a/src/webviews/apps/home/stateProvider.ts +++ b/src/webviews/apps/home/stateProvider.ts @@ -67,6 +67,7 @@ export class HomeStateProvider implements Disposable { case DidChangeIntegrationsConnections.is(msg): this.state.hasAnyIntegrationConnected = msg.params.hasAnyIntegrationConnected; + this.state.integrations = msg.params.integrations; this.state.timestamp = Date.now(); this.provider.setValue(this.state, true); diff --git a/src/webviews/apps/plus/shared/components/account-chip.ts b/src/webviews/apps/plus/shared/components/account-chip.ts new file mode 100644 index 0000000000000..f1d50506451e4 --- /dev/null +++ b/src/webviews/apps/plus/shared/components/account-chip.ts @@ -0,0 +1,528 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import { urls } from '../../../../../constants'; +import { proTrialLengthInDays, SubscriptionPlanId, SubscriptionState } from '../../../../../constants.subscription'; +import type { Source } from '../../../../../constants.telemetry'; +import type { Promo } from '../../../../../plus/gk/account/promos'; +import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; +import { + getSubscriptionPlanTier, + getSubscriptionStateName, + getSubscriptionTimeRemaining, + hasAccountFromSubscriptionState, +} from '../../../../../plus/gk/account/subscription'; +import { createCommandLink } from '../../../../../system/commands'; +import { pluralize } from '../../../../../system/string'; +import type { State } from '../../../../home/protocol'; +import { stateContext } from '../../../home/context'; +import type { GlPopover } from '../../../shared/components/overlays/popover.react'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import { chipStyles } from './chipStyles'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/overlays/popover'; + +@customElement('gl-account-chip') +export class GLAccountChip extends LitElement { + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = [ + elementBase, + linkBase, + chipStyles, + css` + .chip { + padding-right: 0.6rem; + + font-size: 1.1rem; + font-weight: 400; + text-transform: uppercase; + } + + :host-context(.vscode-dark) .chip, + :host-context(.vscode-high-contrast) .chip { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #fff 10%); + } + + :host-context(.vscode-light) .chip, + :host-context(.vscode-high-contrast-light) .chip { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #000 7%); + } + + .chip__media { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + } + + img.chip__media { + width: 1.6rem; + aspect-ratio: 1 / 1; + border-radius: 50%; + } + + :host-context(.vscode-dark) img.chip__media, + :host-context(.vscode-high-contrast) img.chip__media { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #fff 25%); + } + + :host-context(.vscode-light) img.chip__media, + :host-context(.vscode-high-contrast-light) img.chip__media { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #000 18%); + } + + .account-org { + display: flex; + flex-direction: column; + gap: 0.2rem; + } + + .account { + position: relative; + display: flex; + flex-direction: row; + gap: 0 0.6rem; + align-items: center; + } + + .account__media { + flex: 0 0 auto; + width: 3.4rem; + display: flex; + align-items: center; + justify-content: center; + } + + img.account__media { + width: 2rem; + aspect-ratio: 1 / 1; + border-radius: 50%; + } + + :host-context(.vscode-dark) img.account__media, + :host-context(.vscode-high-contrast) img.account__media { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #fff 20%); + } + + :host-context(.vscode-light) img.account__media, + :host-context(.vscode-high-contrast-light) img.account__media { + background-color: color-mix(in lab, var(--vscode-sideBar-background), #000 15%); + } + + .account__details { + display: flex; + flex-direction: column; + justify-content: center; + } + + .account__title { + font-size: 1.3rem; + font-weight: 600; + margin: 0; + } + + .account__email { + font-size: 1.1rem; + font-weight: 400; + margin: 0; + color: var(--color-foreground--65); + } + + .org { + position: relative; + display: flex; + flex-direction: row; + gap: 0 0.6rem; + align-items: center; + margin-bottom: 0.6rem; + } + + .org__media { + flex: none; + width: 3.4rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-foreground--65); + } + + .org__details { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + } + + .org__title { + font-size: 1.3rem; + font-weight: 600; + margin: 0; + } + + .org__access { + position: relative; + margin: 0; + color: var(--color-foreground--65); + } + + .org__signout { + flex: none; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .org__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + line-height: 2.4rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--65); + background-color: var(--vscode-toolbar-hoverBackground); + border-radius: 50%; + margin-right: 0.6rem; + } + + .account-status > :first-child { + margin-block-start: 0; + } + .account-status > :last-child { + margin-block-end: 0; + } + + button-container { + margin-bottom: 1.3rem; + } + + button-container .button-suffix { + display: inline-flex; + align-items: center; + white-space: nowrap; + gap: 0.2em; + margin-left: 0.4rem; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + `, + ]; + + @query('#chip') + private _chip!: HTMLElement; + + @query('gl-popover') + private _popover!: GlPopover; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + private get accountAvatar() { + return this.hasAccount && this._state.avatar; + } + + private get accountName() { + return this.subscription?.account?.name ?? ''; + } + + private get accountEmail() { + return this.subscription?.account?.email ?? ''; + } + + private get hasAccount() { + return hasAccountFromSubscriptionState(this.subscriptionState); + } + + get isReactivatedTrial() { + return ( + this.subscriptionState === SubscriptionState.ProTrial && + (this.subscription?.plan.effective.trialReactivationCount ?? 0) > 0 + ); + } + private get planId() { + return this._state.subscription?.plan.actual.id ?? SubscriptionPlanId.Pro; + } + + private get planName() { + return getSubscriptionStateName(this.subscriptionState, this.planId); + } + + private get planTier() { + return getSubscriptionPlanTier(this.planId); + } + + private get subscription() { + return this._state.subscription; + } + + private get subscriptionState() { + return this.subscription?.state; + } + + private get trialDaysRemaining() { + if (this.subscription == null) return 0; + + return getSubscriptionTimeRemaining(this.subscription, 'days') ?? 0; + } + + override focus() { + this._chip.focus(); + } + + override render() { + return html` + + ${this.accountAvatar + ? html`` + : html``} + ${this.planTier} + +
+
+ ${this.planName} + + ${this.hasAccount + ? html` + + ` + : html``} + +
+ ${this.renderOrganization()} ${this.renderAccountState()} +
+
`; + } + + show() { + void this._popover.show(); + this.focus(); + } + + private renderOrganization() { + const organization = this._state.subscription?.activeOrganization?.name ?? ''; + if (!this.hasAccount || !organization) return nothing; + + return html``; + } + + private renderAccountState() { + const promo = getApplicablePromo(this.subscriptionState, 'account'); + + switch (this.subscriptionState) { + case SubscriptionState.Paid: + return html` `; + + case SubscriptionState.VerificationRequired: + return html``; + + case SubscriptionState.ProTrial: { + const days = this.trialDaysRemaining; + + return html``; + } + + case SubscriptionState.ProTrialExpired: + return html``; + + case SubscriptionState.ProTrialReactivationEligible: + return html``; + + default: + return html``; + } + } + + private renderIncludesDevEx() { + return html`

Includes access to GitKraken's DevEx platform

`; + } + + private renderPromo(promo: Promo | undefined) { + return html``; + } +} diff --git a/src/webviews/apps/plus/shared/components/chipStyles.ts b/src/webviews/apps/plus/shared/components/chipStyles.ts new file mode 100644 index 0000000000000..7e28db107008d --- /dev/null +++ b/src/webviews/apps/plus/shared/components/chipStyles.ts @@ -0,0 +1,53 @@ +import { css } from 'lit'; + +export const chipStyles = css` + :host { + display: flex; + } + + .chip { + display: flex; + gap: 0.6rem; + align-items: center; + + border-radius: 0.3rem; + padding: 0.2rem 0.4rem; + cursor: pointer; + } + + .chip:focus, + .chip:focus-within { + outline: 1px solid var(--vscode-focusBorder); + } + + .content { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding-bottom: 0.4rem; + } + + .header { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding-bottom: 0.4rem; + } + + .header__actions { + flex: none; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .header__title { + flex: 1; + font-size: 1.5rem; + font-weight: 600; + margin: 0; + } +`; diff --git a/src/webviews/apps/plus/shared/components/home-account-content.ts b/src/webviews/apps/plus/shared/components/home-account-content.ts deleted file mode 100644 index 059f2b9707a16..0000000000000 --- a/src/webviews/apps/plus/shared/components/home-account-content.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { consume } from '@lit/context'; -import { css, html, LitElement, nothing } from 'lit'; -import { customElement, query, state } from 'lit/decorators.js'; -import { when } from 'lit/directives/when.js'; -import { urls } from '../../../../../constants'; -import { proTrialLengthInDays, SubscriptionPlanId, SubscriptionState } from '../../../../../constants.subscription'; -import type { Promo } from '../../../../../plus/gk/account/promos'; -import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; -import { - getSubscriptionPlanName, - getSubscriptionStateName, - getSubscriptionTimeRemaining, - hasAccountFromSubscriptionState, -} from '../../../../../plus/gk/account/subscription'; -import { pluralize } from '../../../../../system/string'; -import type { State } from '../../../../home/protocol'; -import { stateContext } from '../../../home/context'; -import type { GlAccordion } from '../../../shared/components/accordion/accordion'; -import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; -import '../../../shared/components/accordion/accordion'; -import '../../../shared/components/button'; -import '../../../shared/components/button-container'; -import '../../../shared/components/code-icon'; -import '../../../shared/components/promo'; - -@customElement('gl-home-account-content') -export class GLHomeAccountContent extends LitElement { - static override shadowRootOptions: ShadowRootInit = { - ...LitElement.shadowRootOptions, - delegatesFocus: true, - }; - - static override styles = [ - elementBase, - linkBase, - css` - :host { - display: block; - margin-bottom: 1.3rem; - --gl-accordion-content-background: var(--vscode-sideBar-background); - --gl-accordion-header-background: var(--vscode-sideBarSectionHeader-background); - } - - :host > * { - margin-bottom: 0; - } - - button-container { - margin-bottom: 1.3rem; - } - - button-container .button-suffix { - display: inline-flex; - align-items: center; - white-space: nowrap; - gap: 0.2em; - margin-left: 0.4rem; - } - - gl-accordion { - border-top: 1px solid var(--vscode-sideBarSectionHeader-border); - } - - .header { - display: flex; - align-items: center; - gap: 0.6rem; - } - - .header__media { - flex: none; - } - - .header__actions { - flex: none; - display: flex; - gap: 0.2rem; - flex-direction: row; - align-items: center; - justify-content: center; - } - - img.header__media { - width: 3rem; - aspect-ratio: 1 / 1; - border-radius: 50%; - } - - .header__title { - flex: 1; - font-size: 1.5rem; - font-weight: 600; - margin: 0; - } - - .org { - position: relative; - display: flex; - flex-direction: row; - gap: 0 0.8rem; - align-items: center; - margin-bottom: 1.3rem; - } - - .org__media { - flex: none; - width: 3.4rem; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-foreground--65); - } - - .org__image { - width: 100%; - aspect-ratio: 1 / 1; - border-radius: 50%; - } - - .org__details { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - } - - .org__title { - font-size: 1.3rem; - font-weight: 600; - margin: 0; - } - - .org__access { - position: relative; - margin: 0; - color: var(--color-foreground--65); - } - - .org__signout { - flex: none; - display: flex; - gap: 0.2rem; - flex-direction: row; - align-items: center; - justify-content: center; - } - - .org__badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.4rem; - height: 2.4rem; - line-height: 2.4rem; - font-size: 1rem; - font-weight: 600; - color: var(--color-foreground--65); - background-color: var(--vscode-toolbar-hoverBackground); - border-radius: 50%; - margin-right: 0.6rem; - } - - .account > :first-child { - margin-block-start: 0; - } - .account > :last-child { - margin-block-end: 0; - } - - hr { - border: none; - border-top: 1px solid var(--color-foreground--25); - } - `, - ]; - - @query('#accordion') - private accordionEl!: GlAccordion; - - @consume({ context: stateContext, subscribe: true }) - @state() - private _state!: State; - - private get daysRemaining() { - if (this._state.subscription == null) return 0; - - return getSubscriptionTimeRemaining(this._state.subscription, 'days') ?? 0; - } - - get hasAccount() { - return hasAccountFromSubscriptionState(this.state); - } - - get isReactivatedTrial() { - return ( - this.state === SubscriptionState.ProTrial && - (this._state.subscription?.plan.effective.trialReactivationCount ?? 0) > 0 - ); - } - - private get planId() { - return this._state.subscription?.plan.actual.id ?? SubscriptionPlanId.Pro; - } - - get planName() { - return getSubscriptionStateName(this.state, this.planId); - } - - private get state() { - return this._state.subscription?.state; - } - - override render() { - return html` -
- ${this.hasAccount && this._state.avatar - ? html`` - : html``} - ${this.planName} - ${when( - this.state === SubscriptionState.ProTrialReactivationEligible, - () => html` - Reactivate Pro Trial - `, - )} - ${when( - this.hasAccount, - () => html` - - ${when( - !this._state.hasAnyIntegrationConnected, - () => html` - - `, - )} - - - - `, - )} -
- ${this.renderOrganization()}${this.renderAccountState()} - -
`; - } - - private renderOrganization() { - const organization = this._state.subscription?.activeOrganization?.name ?? ''; - if (!this.hasAccount || !organization) return nothing; - - return html` -
-
- -
-
-

${organization}

-
- ${when( - this._state.organizationsCount! > 1, - () => - html`
- +${this._state.organizationsCount! - 1}Switch Active Organization -
- You are in - ${pluralize('organization', this._state.organizationsCount! - 1, { - infix: ' other ', - })}
-
`, - )} -
- `; - } - - private renderAccountState() { - const promo = getApplicablePromo(this.state, 'account'); - - switch (this.state) { - case SubscriptionState.Paid: - return html` - - `; - - case SubscriptionState.VerificationRequired: - return html` - - `; - - case SubscriptionState.ProTrial: { - const days = this.daysRemaining; - - return html` - - `; - } - - case SubscriptionState.ProTrialExpired: - return html` - - `; - - case SubscriptionState.ProTrialReactivationEligible: - return html` - - `; - - default: - return html` - - `; - } - } - - private renderIncludesDevEx() { - return html` -

- Includes access to the GitKraken DevEx platform, unleashing powerful Git - visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal. -

- `; - } - - private renderPromo(promo: Promo | undefined) { - return html``; - } - - override focus() { - this.accordionEl.focus(); - } - - show() { - this.accordionEl.open = true; - this.accordionEl.focus(); - } -} diff --git a/src/webviews/apps/plus/shared/components/home-header.ts b/src/webviews/apps/plus/shared/components/home-header.ts new file mode 100644 index 0000000000000..3662bfff0a57e --- /dev/null +++ b/src/webviews/apps/plus/shared/components/home-header.ts @@ -0,0 +1,70 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, query } from 'lit/decorators.js'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import type { GLAccountChip } from './account-chip'; +import './account-chip'; +import './integrations-chip'; +import '../../../home/components/onboarding'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/overlays/popover'; +import '../../../shared/components/promo'; + +@customElement('gl-home-header') +export class GLHomeHeader extends LitElement { + static override styles = [ + elementBase, + linkBase, + css` + :host { + display: block; + } + + .container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + } + + .container:focus, + .container:focus-within { + outline: none; + } + + /* .actions { + flex: none; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } */ + + gl-promo-banner { + margin: 0 0.2rem 0.6rem; + } + + gl-promo-banner:not([has-promo]) { + display: none; + } + `, + ]; + + @query('gl-account-chip') + private accountChip!: GLAccountChip; + + override render() { + return html` +
+ + +
+ `; + } + + show() { + this.accountChip.show(); + } +} diff --git a/src/webviews/apps/plus/shared/components/integrations-chip.ts b/src/webviews/apps/plus/shared/components/integrations-chip.ts new file mode 100644 index 0000000000000..51653eec5f041 --- /dev/null +++ b/src/webviews/apps/plus/shared/components/integrations-chip.ts @@ -0,0 +1,261 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import type { + ConnectCloudIntegrationsCommandArgs, + ManageCloudIntegrationsCommandArgs, +} from '../../../../../commands/cloudIntegrations'; +import type { IntegrationFeatures } from '../../../../../constants.integrations'; +import type { Source } from '../../../../../constants.telemetry'; +import { hasAccountFromSubscriptionState } from '../../../../../plus/gk/account/subscription'; +import { createCommandLink } from '../../../../../system/commands'; +import type { IntegrationState, State } from '../../../../home/protocol'; +import { stateContext } from '../../../home/context'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import { chipStyles } from './chipStyles'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/overlays/popover'; +import '../../../shared/components/overlays/tooltip'; + +@customElement('gl-integrations-chip') +export class GLIntegrationsChip extends LitElement { + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = [ + elementBase, + linkBase, + chipStyles, + css` + .chip { + gap: 0.8rem; + padding: 0.2rem 0.4rem 0.4rem 0.4rem; + align-items: baseline; + } + + .chip__label { + font-size: 1.1rem; + font-weight: 400; + text-transform: uppercase; + color: var(--color-foreground--75); + margin-right: 0.4rem; + } + + .integration { + white-space: nowrap; + } + + .content { + gap: 0.6rem; + } + + .status--disconnected.integration { + color: var(--color-foreground--25); + } + + :host-context(.vscode-dark) .status--connected .status-indicator, + :host-context(.vscode-high-contrast) .status--connected .status-indicator { + color: #00dd00; + } + + :host-context(.vscode-light) .status--connected .status-indicator, + :host-context(.vscode-high-contrast-light) .status--connected .status-indicator { + color: #00aa00; + } + + gl-tooltip.status-indicator { + margin-right: 0.4rem; + } + + .integrations { + display: flex; + flex-direction: column; + gap: 0.8rem; + width: 100%; + } + + .integration-row { + display: flex; + gap: 1rem; + align-items: center; + } + + .status--disconnected .integration__icon { + color: var(--color-foreground--25); + } + + .status--disconnected .integration__title { + color: var(--color-foreground--50); + } + + .integration__details { + display: flex; + color: var(--color-foreground--75); + font-size: 1rem; + } + + .status--disconnected .integration__details { + color: var(--color-foreground--50); + } + + .integration__actions { + flex: 1 1 auto; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: flex-start; + justify-content: flex-end; + } + + button-container { + margin-bottom: 0.4rem; + width: 100%; + } + + p { + margin: 0; + } + `, + ]; + + @query('#chip') + private _chip!: HTMLElement; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + private get hasAccount() { + return hasAccountFromSubscriptionState(this._state.subscription?.state); + } + + private get hasConnectedIntegrations() { + return this.hasAccount && this.integrations.some(i => i.connected); + } + + private get integrations() { + return this._state.integrations; + } + + override focus() { + this._chip.focus(); + } + + override render() { + const anyConnected = this.hasConnectedIntegrations; + return html` + ${!anyConnected ? html`Connect` : ''}${this.integrations.map(i => + this.renderIntegrationStatus(i, anyConnected), + )} +
+
+ Integrations + + + +
+
${ + !anyConnected + ? html`

+ Connect hosting services like GitHub and issue trackers like + Jira to track progress and take action on PRs and issues related to + your branches. +

+ + Connect Integrations + ` + : this.integrations.map(i => this.renderIntegrationRow(i)) + }
+
+
`; + } + + private renderIntegrationStatus(integration: IntegrationState, anyConnected: boolean) { + return html`${anyConnected + ? html`` + : nothing}`; + } + + private renderIntegrationRow(integration: IntegrationState) { + return html`
+ + + ${integration.name} + ${getIntegrationDetails(integration)} + + + ${integration.connected + ? html`` + : html``} + +
`; + } +} +const featureMap = new Map([ + ['prs', 'Pull Requests'], + ['issues', 'Issues'], +]); +function getIntegrationDetails(integration: IntegrationState): string { + const features = integration.supports.map(feature => featureMap.get(feature)!); + + if (features.length === 0) return ''; + if (features.length === 1) return `Supports ${features[0]}`; + + const last = features.pop(); + return `Supports ${features.join(', ')} and ${last}`; +} diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 3d2184e12df32..0a502b6ad69c4 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -8,7 +8,12 @@ import type { OpenPullRequestOnRemoteCommandArgs } from '../../commands/openPull import { GlyphChars, urls } from '../../constants'; import { GlCommand } from '../../constants.commands'; import type { ContextKeys } from '../../constants.context'; -import type { HomeTelemetryContext } from '../../constants.telemetry'; +import { + isSupportedCloudIntegrationId, + supportedCloudIntegrationDescriptors, + supportedOrderedCloudIntegrationIds, +} from '../../constants.integrations'; +import type { HomeTelemetryContext, Source } from '../../constants.telemetry'; import type { Container } from '../../container'; import { executeGitCommand } from '../../git/actions'; import { openComparisonChanges } from '../../git/actions/commit'; @@ -34,7 +39,7 @@ import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/pro import { showRepositoryPicker } from '../../quickpicks/repositoryPicker'; import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; -import { map } from '../../system/iterable'; +import { filterMap, map } from '../../system/iterable'; import { getSettledValue } from '../../system/promise'; import { executeActionCommand, executeCommand, registerCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; @@ -49,6 +54,7 @@ import type { GetOverviewBranch, GetOverviewBranches, GetOverviewResponse, + IntegrationState, OpenInGraphParams, OverviewFilters, OverviewRecentThreshold, @@ -170,7 +176,7 @@ export class HomeWebviewProvider implements WebviewProvider { @@ -274,7 +280,7 @@ export class HomeWebviewProvider implements WebviewProvider openUrl(urls.githubDiscussions), this), registerCommand( `${this.host.id}.account.resync`, - () => this.container.subscription.validate({ force: true }), + (src?: Source) => this.container.subscription.validate({ force: true }, src), this, ), registerCommand('gitlens.home.openPullRequestChanges', this.pullRequestChanges, this), @@ -488,20 +494,31 @@ export class HomeWebviewProvider implements WebviewProvider { - const subResult = await this.getSubscriptionState(subscription); + const [subResult, integrationResult] = await Promise.allSettled([ + this.getSubscriptionState(subscription), + this.getIntegrationStates(true), + ]); + + if (subResult.status === 'rejected') { + throw subResult.reason; + } + + const integrations = getSettledValue(integrationResult) ?? []; + const anyConnected = integrations.some(i => i.connected); return { ...this.host.baseWebviewState, discovering: this._discovering != null, repositories: this.getRepositoriesState(), webroot: this.host.getWebRoot(), - subscription: subResult.subscription, - avatar: subResult.avatar, - organizationsCount: subResult.organizationsCount, + subscription: subResult.value.subscription, + avatar: subResult.value.avatar, + organizationsCount: subResult.value.organizationsCount, orgSettings: this.getOrgSettings(), previewCollapsed: this.getPreviewCollapsed(), integrationBannerCollapsed: this.getIntegrationBannerCollapsed(), - hasAnyIntegrationConnected: this.isAnyIntegrationConnected(), + integrations: integrations, + hasAnyIntegrationConnected: anyConnected, walkthroughProgress: { allCount: this.container.walkthrough.walkthroughSize, doneCount: this.container.walkthrough.doneCount, @@ -711,16 +728,45 @@ export class HomeWebviewProvider implements WebviewProvider 0; + private _integrationStates: IntegrationState[] | undefined; + private _defaultSupportedCloudIntegrations: IntegrationState[] | undefined; + + private async getIntegrationStates(force = false) { + if (force || this._integrationStates == null) { + const promises = filterMap(this.container.integrations.getLoaded(), async i => + isSupportedCloudIntegrationId(i.id) + ? ({ + id: i.id, + name: i.name, + icon: `gl-provider-${i.icon}`, + connected: i.maybeConnected ?? (await i.isConnected()), + supports: i.type === 'hosting' ? ['prs', 'issues'] : i.type === 'issues' ? ['issues'] : [], + } satisfies IntegrationState) + : undefined, + ); + + const integrationsResults = await Promise.allSettled(promises); + const integrations = [...filterMap(integrationsResults, r => getSettledValue(r))]; + + this._defaultSupportedCloudIntegrations ??= supportedCloudIntegrationDescriptors.map(d => ({ + ...d, + connected: false, + })); + + // union (uniquely by id) with supportedCloudIntegrationDescriptors + integrations.push( + ...this._defaultSupportedCloudIntegrations.filter(d => !integrations.some(i => i.id === d.id)), + ); + integrations.sort( + (a, b) => + supportedOrderedCloudIntegrationIds.indexOf(a.id) - + supportedOrderedCloudIntegrationIds.indexOf(b.id), + ); + + this._integrationStates = integrations; } - return this._hostedIntegrationConnected; + + return this._integrationStates; } private _subscription: Subscription | undefined; @@ -804,17 +850,19 @@ export class HomeWebviewProvider implements WebviewProvider i.connected); + if (anyConnected) { this.onCollapseSection({ section: 'integrationBanner', collapsed: true, }); } void this.host.notify(DidChangeIntegrationsConnections, { - hasAnyIntegrationConnected: isConnected, + hasAnyIntegrationConnected: anyConnected, + integrations: integrations, }); } diff --git a/src/webviews/home/protocol.ts b/src/webviews/home/protocol.ts index c700e851f279a..968530c6873ff 100644 --- a/src/webviews/home/protocol.ts +++ b/src/webviews/home/protocol.ts @@ -1,3 +1,4 @@ +import type { IntegrationDescriptor } from '../../constants.integrations'; import type { GitBranchStatus, GitTrackingState } from '../../git/models/branch'; import type { Subscription } from '../../plus/gk/account/subscription'; import type { LaunchpadSummaryResult } from '../../plus/launchpad/launchpadIndicator'; @@ -17,6 +18,7 @@ export interface State extends WebviewState { previewCollapsed: boolean; integrationBannerCollapsed: boolean; hasAnyIntegrationConnected: boolean; + integrations: IntegrationState[]; avatar?: string; organizationsCount?: number; walkthroughProgress: { @@ -29,6 +31,10 @@ export interface State extends WebviewState { newInstall: boolean; } +export interface IntegrationState extends IntegrationDescriptor { + connected: boolean; +} + export type OverviewRecentThreshold = 'OneDay' | 'OneWeek' | 'OneMonth'; export type OverviewStaleThreshold = 'OneYear'; @@ -202,6 +208,7 @@ export const DidChangeWalkthroughProgress = new IpcNotification( scope,