diff --git a/src/webviews/apps/home/components/feature-nav.ts b/src/webviews/apps/home/components/feature-nav.ts new file mode 100644 index 0000000000000..377e7aa4c0b83 --- /dev/null +++ b/src/webviews/apps/home/components/feature-nav.ts @@ -0,0 +1,412 @@ +import { consume } from '@lit/context'; +import { css, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../home/protocol'; +import { GlElement } from '../../shared/components/element'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { homeBaseStyles, navListStyles } from '../home.css'; + +@customElement('gl-feature-nav') +export class GlFeatureNav extends GlElement { + static override styles = [linkBase, homeBaseStyles, navListStyles, css``]; + + @property({ type: Object }) + private badgeSource = { source: 'home', detail: 'badge' }; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + get orgAllowsDrafts() { + return this._state.orgSettings.drafts; + } + + private get blockRepoFeatures() { + if (!this._state) return true; + + const { + repositories: { openCount, hasUnsafe, trusted }, + } = this._state; + return !trusted || openCount === 0 || hasUnsafe; + } + + private onRepoFeatureClicked(e: MouseEvent) { + if (this.blockRepoFeatures) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + return true; + } + + override render() { + return html` + ${when( + this.blockRepoFeatures, + () => html` +

+ Features which need a repository are currently + unavailable +

+ `, + )} + + + + + + `; + } +} diff --git a/src/webviews/apps/home/components/home-nav.ts b/src/webviews/apps/home/components/home-nav.ts new file mode 100644 index 0000000000000..e56eb346863a2 --- /dev/null +++ b/src/webviews/apps/home/components/home-nav.ts @@ -0,0 +1,106 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { getApplicablePromo } from '../../../../plus/gk/account/promos'; +import type { State } from '../../../home/protocol'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { homeBaseStyles, inlineNavStyles } from '../home.css'; +import '../../shared/components/code-icon'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/promo'; + +@customElement('gl-home-nav') +export class GlHomeNav extends LitElement { + static override styles = [ + linkBase, + homeBaseStyles, + inlineNavStyles, + css` + :host { + display: block; + } + `, + ]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + override render() { + return html` + + + `; + } +} diff --git a/src/webviews/apps/home/components/onboarding.ts b/src/webviews/apps/home/components/onboarding.ts new file mode 100644 index 0000000000000..a435d12f36fe1 --- /dev/null +++ b/src/webviews/apps/home/components/onboarding.ts @@ -0,0 +1,105 @@ +import { consume } from '@lit/context'; +import { html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import type { State } from '../../../home/protocol'; +import { CollapseSectionCommand } from '../../../home/protocol'; +import { ipcContext } from '../../shared/context'; +import type { HostIpc } from '../../shared/ipc'; +import { stateContext } from '../context'; +import { alertStyles, buttonStyles, homeBaseStyles } from '../home.css'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/overlays/tooltip'; + +@customElement('gl-onboarding') +export class GlOnboarding extends LitElement { + static override styles = [alertStyles, homeBaseStyles, buttonStyles]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + @consume({ context: ipcContext, subscribe: true }) + @state() + private _ipc!: HostIpc; + + private onSectionExpandClicked(e: MouseEvent, isToggle = false) { + if (isToggle) { + e.stopImmediatePropagation(); + } + const target = (e.target as HTMLElement).closest('[data-section-expand]') as HTMLElement; + const section = target?.dataset.sectionExpand; + if (section !== 'walkthrough') { + return; + } + + if (isToggle) { + this.updateCollapsedSections(!this._state.walkthroughCollapsed); + return; + } + + this.updateCollapsedSections(false); + } + + private updateCollapsedSections(toggle = this._state.walkthroughCollapsed) { + this._state.walkthroughCollapsed = toggle; + this.requestUpdate(); + this._ipc.sendCommand(CollapseSectionCommand, { + section: 'walkthrough', + collapsed: toggle, + }); + } + + override render() { + return html` +
this.onSectionExpandClicked(e)} + > +

Get Started with GitLens

+
+

Explore all of the powerful features in GitLens

+

+ Start Here (Welcome) + + Walkthrough + Tutorial + +

+
+ this.onSectionExpandClicked(e, true)} + > + + + Collapse + + + + Expand + + +
+ `; + } +} diff --git a/src/webviews/apps/home/components/repo-alerts.ts b/src/webviews/apps/home/components/repo-alerts.ts new file mode 100644 index 0000000000000..22f8e921a2143 --- /dev/null +++ b/src/webviews/apps/home/components/repo-alerts.ts @@ -0,0 +1,138 @@ +import { consume } from '@lit/context'; +import { css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../home/protocol'; +import { GlElement } from '../../shared/components/element'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { alertStyles, homeBaseStyles } from '../home.css'; +import '../../shared/components/button'; + +@customElement('gl-repo-alerts') +export class GlRepoAlerts extends GlElement { + static override styles = [ + linkBase, + homeBaseStyles, + alertStyles, + css` + .alert { + margin-bottom: 0; + } + + .centered { + text-align: center; + } + + .one-line { + white-space: nowrap; + } + + gl-button.is-basic { + max-width: 300px; + width: 100%; + } + gl-button.is-basic + gl-button.is-basic { + margin-top: 1rem; + } + `, + ]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + get alertVisibility() { + const sections = { + header: false, + untrusted: false, + noRepo: false, + unsafeRepo: false, + }; + if (this._state == null) { + return sections; + } + + if (!this._state.repositories.trusted) { + sections.header = true; + sections.untrusted = true; + } else if (this._state.repositories.openCount === 0) { + sections.header = true; + sections.noRepo = true; + } else if (this._state.repositories.hasUnsafe) { + sections.header = true; + sections.unsafeRepo = true; + } + + return sections; + } + + override render() { + if (this._state == null || !this.alertVisibility.header) { + return; + } + + return html` + ${when( + this.alertVisibility.noRepo, + () => html` +
+

No repository detected

+
+

+ To use GitLens, open a folder containing a git repository or clone from a URL from the + Explorer. +

+

+ Open a Folder or Repository +

+

+ If you have opened a folder with a repository, please let us know by + creating an Issue. +

+
+
+ `, + )} + ${when( + this.alertVisibility.unsafeRepo, + () => html` +
+

Unsafe repository

+
+

+ Unable to open any repositories as Git blocked them as potentially unsafe, due to the + folder(s) not being owned by the current user. +

+

+ Manage in Source Control +

+
+
+ `, + )} + ${when( + this.alertVisibility.untrusted, + () => html` + + `, + )} + `; + } +} diff --git a/src/webviews/apps/home/context.ts b/src/webviews/apps/home/context.ts new file mode 100644 index 0000000000000..ff3a6b239bf29 --- /dev/null +++ b/src/webviews/apps/home/context.ts @@ -0,0 +1,4 @@ +import { createContext } from '@lit/context'; +import type { State } from '../../home/protocol'; + +export const stateContext = createContext('state'); diff --git a/src/webviews/apps/home/home.css.ts b/src/webviews/apps/home/home.css.ts new file mode 100644 index 0000000000000..4490b22b69dcf --- /dev/null +++ b/src/webviews/apps/home/home.css.ts @@ -0,0 +1,345 @@ +import { css } from 'lit'; + +export const homeBaseStyles = css` + * { + box-sizing: border-box; + } + + :not(:defined) { + visibility: hidden; + } + + [hidden] { + display: none !important; + } + + /* roll into shared focus style */ + :focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + b { + font-weight: 600; + } + + p { + margin-top: 0; + } + + ul { + margin-top: 0; + padding-left: 1.2em; + } +`; + +export const homeStyles = css` + .home { + padding: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 0.4rem; + overflow: hidden; + } + .home__header { + flex: none; + padding: 0 2rem; + position: relative; + } + .home__main { + flex: 1; + overflow: auto; + padding: 0.8rem 2rem; + } + .home__main > *:last-child { + margin-bottom: 0; + } + .home__nav { + flex: none; + padding: 0; + margin-block: 0.6rem -1rem; + } + .home__footer { + flex: none; + } + + gl-home-account-content { + margin-bottom: 0; + } +`; + +export const inlineNavStyles = css` + .inline-nav { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .inline-nav__group { + display: flex; + flex-direction: row; + } + .inline-nav__link { + display: flex; + justify-content: center; + align-items: center; + width: 2.2rem; + height: 2.2rem; + color: inherit; + border-radius: 0.3rem; + } + .inline-nav__link .code-icon { + line-height: 1.6rem; + } + .inline-nav__link:hover { + color: inherit; + text-decoration: none; + } + :host-context(.vscode-dark) .inline-nav__link:hover { + background-color: var(--color-background--lighten-10); + } + :host-context(.vscode-light) .inline-nav__link:hover { + background-color: var(--color-background--darken-10); + } + @media (max-width: 370px) { + .inline-nav__link--text > :last-child { + display: none; + } + } + @media (min-width: 371px) { + .inline-nav__link--text { + flex: none; + padding-left: 0.3rem; + padding-right: 0.3rem; + gap: 0.2rem; + min-width: 2.2rem; + width: fit-content; + } + .inline-nav__link--text + .inline-nav__link--text { + margin-left: 0.2rem; + } + } + + .promo-banner { + text-align: center; + margin-bottom: 1rem; + } + .promo-banner--eyebrow { + color: var(--color-foreground--50); + margin-bottom: 0.2rem; + } +`; + +export const buttonStyles = css` + .button-container { + margin: 1rem auto 0; + text-align: left; + max-width: 30rem; + transition: max-width 0.2s ease-out; + } + + @media (min-width: 640px) { + .button-container { + max-width: 100%; + } + } + .button-container--trio > gl-button:first-child { + margin-bottom: 0.4rem; + } + + .button-group { + display: inline-flex; + gap: 0.4rem; + } + .button-group--single { + width: 100%; + max-width: 30rem; + } + .button-group gl-button { + margin-top: 0; + } + .button-group gl-button:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + .button-group gl-button:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; + +export const alertStyles = css` + .alert { + position: relative; + padding: 0.8rem 1.2rem; + line-height: 1.2; + margin-bottom: 1.2rem; + background-color: var(--color-alert-neutralBackground); + border-left: 0.3rem solid var(--color-alert-neutralBorder); + color: var(--color-alert-foreground); + } + .alert__title { + font-size: 1.4rem; + margin: 0; + } + .alert__description { + font-size: 1.2rem; + margin: 0.4rem 0 0; + } + .alert__description > :first-child { + margin-top: 0; + } + .alert__description > :last-child { + margin-bottom: 0; + } + .alert__close { + position: absolute; + top: 0.8rem; + right: 0.8rem; + color: inherit; + opacity: 0.64; + } + .alert__close:hover { + color: inherit; + opacity: 1; + } + .alert.is-collapsed { + cursor: pointer; + } + .alert.is-collapsed:hover { + background-color: var(--color-alert-neutralHoverBackground); + } + .alert.is-collapsed .alert__description, + .alert.is-collapsed .alert__close gl-tooltip:first-child, + .alert:not(.is-collapsed) .alert__close gl-tooltip:last-child { + display: none; + } + .alert--info { + background-color: var(--color-alert-infoBackground); + border-left-color: var(--color-alert-infoBorder); + } + .alert--warning { + background-color: var(--color-alert-warningBackground); + border-left-color: var(--color-alert-warningBorder); + } + .alert--danger { + background-color: var(--color-alert-errorBackground); + border-left-color: var(--color-alert-errorBorder); + } +`; + +export const navListStyles = css` + .nav-list { + margin-left: -2rem; + margin-right: -2rem; + display: flex; + flex-direction: column; + gap: 0.1rem; + align-items: stretch; + margin-bottom: 1.6rem; + } + .nav-list__item { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.8rem; + padding: 0.4rem 2rem; + } + .nav-list__item:hover, + .nav-list__item:focus-within { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-list-hoverForeground); + } + .nav-list__item:has(:first-child:focus) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + .nav-list__item:has(:active) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + } + .nav-list__item:has(.is-disabled) { + cursor: not-allowed; + } + .nav-list__link { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.8rem; + color: inherit; + } + .nav-list__link:hover, + .nav-list__link:focus { + color: inherit; + text-decoration: none; + } + .nav-list__link:focus { + outline: none; + } + .nav-list__link.is-disabled, + .nav-list__link.is-disabled:hover { + opacity: 0.5; + pointer-events: none; + text-decoration: none; + } + .nav-list__icon { + flex: none; + opacity: 0.5; + } + .nav-list__label { + flex: 1; + font-weight: 600; + } + .nav-list__desc { + color: var(--color-foreground--65); + font-variant: all-small-caps; + margin-left: 1rem; + } + .nav-list__group { + width: 100%; + display: flex; + justify-content: flex-start; + } + .nav-list__group .nav-list__label { + width: auto; + } + .nav-list__access { + flex: none; + position: relative; + left: 1.5rem; + font-size: x-small; + outline: none; + white-space: nowrap; + --gl-feature-badge-color: color-mix(in srgb, transparent 40%, currentColor); + --gl-feature-badge-border-color: color-mix(in srgb, transparent 40%, var(--color-foreground--50)); + } + .nav-list__item:hover .nav-list__label { + text-decoration: underline; + } + .nav-list__item:hover .is-disabled .nav-list__label { + text-decoration: none; + } + .nav-list__item:hover .nav-list__desc { + color: var(--color-foreground); + } + .nav-list__item:focus-within .nav-list__access, + .nav-list__item:hover .nav-list__access { + --gl-feature-badge-color: currentColor; + --gl-feature-badge-border-color: var(--color-foreground--50); + } + .nav-list__title { + padding: 0 2rem; + } + + .t-eyebrow { + text-transform: uppercase; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--50); + margin: 0; + } + .t-eyebrow.sticky { + top: -8px; + } +`; diff --git a/src/webviews/apps/home/home.html b/src/webviews/apps/home/home.html index f50002c76a478..de0b59853460d 100644 --- a/src/webviews/apps/home/home.html +++ b/src/webviews/apps/home/home.html @@ -17,472 +17,10 @@ -
- - -
- -
-

- Features which need a repository are currently unavailable -

-
-

Get Started with GitLens

-
-

Explore all of the powerful features in GitLens

-

- Start Here (Welcome) - - Walkthrough - Tutorial - -

-
- - - - Collapse - - - - Expand - - -
- - - - - -
- - #{endOfBody} + diff --git a/src/webviews/apps/home/home.scss b/src/webviews/apps/home/home.scss index de0456127403d..58e0a808fe2d4 100644 --- a/src/webviews/apps/home/home.scss +++ b/src/webviews/apps/home/home.scss @@ -1,35 +1,15 @@ -@use '../shared/styles/utils'; @use '../shared/styles/properties'; @use '../shared/styles/theme'; @use '../shared/styles/scrollbars'; -:root { - --gitlens-z-inline: 1000; - --gitlens-z-sticky: 1100; - --gitlens-z-popover: 1200; - --gitlens-z-cover: 1300; - --gitlens-z-dialog: 1400; - --gitlens-z-modal: 1500; - --gitlens-brand-color: #914db3; - --gitlens-brand-color-2: #a16dc4; -} - .vscode-high-contrast, .vscode-dark { - --progress-bar-color: var(--color-background--lighten-15); - --card-background: var(--color-background--lighten-075); - --card-hover-background: var(--color-background--lighten-10); --popover-bg: var(--color-background--lighten-15); - --promo-banner-dark-display: inline-block; } .vscode-high-contrast-light, .vscode-light { - --progress-bar-color: var(--color-background--darken-15); - --card-background: var(--color-background--darken-075); - --card-hover-background: var(--color-background--darken-10); --popover-bg: var(--color-background--darken-15); - --promo-banner-light-display: inline-block; } * { @@ -54,6 +34,7 @@ html { } body { + padding: 0; background-color: var(--color-view-background); color: var(--color-view-foreground); font-family: var(--font-family); @@ -62,431 +43,4 @@ body { font-size: var(--vscode-font-size); } -@include scrollbars.scrollableBase(); - -:focus { - @include utils.focus(); -} - -.sr-only, -.sr-only-focusable:not(:active):not(:focus) { - clip: rect(0 0 0 0); - clip-path: inset(50%); - width: 1px; - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; -} - -a { - text-decoration: none; - - &:focus { - @include utils.focus(); - } - - &:hover { - text-decoration: underline; - } -} - -b { - font-weight: 600; -} - -p { - margin-top: 0; -} - -ul { - margin-top: 0; - padding-left: 1.2em; -} - -.promo-banner { - text-align: center; - margin-bottom: 1rem; - - &--eyebrow { - color: var(--color-foreground--50); - margin-bottom: 0.2rem; - } -} - -.home { - padding: 0; - height: 100%; - display: flex; - flex-direction: column; - gap: 0.4rem; - overflow: hidden; - - &__header { - flex: none; - padding: 0 2rem; - position: relative; - } - &__main { - flex: 1; - overflow: auto; - padding: 0.8rem 2rem 0.4rem; - - background: - linear-gradient(var(--color-view-background) 33%, var(--color-view-background)), - linear-gradient(var(--color-view-background), var(--color-view-background) 66%) 0 100%, - linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)), - linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)) 0 100%; - background-color: var(--color-view-background); - background-repeat: no-repeat; - background-attachment: local, local, scroll, scroll; - background-size: - 100% 12px, - 100% 12px, - 100% 6px, - 100% 6px; - } - &__nav { - flex: none; - padding: 0 2rem; - } - - &__header:not([hidden]) + &__main [data-requires='repo'] { - opacity: 0.5; - cursor: not-allowed; - &:after { - opacity: 0.5; - } - } - - &__header[hidden] + &__main [data-requires='norepo'] { - display: none; - } -} - -.centered { - text-align: center; -} - -.one-line { - white-space: nowrap; -} - -.foreground { - color: var(--color-view-foreground); -} - -.inline-nav { - display: flex; - flex-direction: row; - justify-content: space-between; - - &__group { - display: flex; - flex-direction: row; - } - - &__link { - display: flex; - justify-content: center; - align-items: center; - width: 2.2rem; - height: 2.2rem; - // line-height: 2.2rem; - color: inherit; - border-radius: 0.3rem; - - .code-icon { - line-height: 1.6rem; - } - - &:hover { - color: inherit; - text-decoration: none; - - .vscode-dark & { - background-color: var(--color-background--lighten-10); - } - .vscode-light & { - background-color: var(--color-background--darken-10); - } - } - - &--text { - @media (max-width: 370px) { - > :last-child { - display: none; - } - } - - @media (min-width: 371px) { - flex: none; - padding: { - left: 0.3rem; - right: 0.3rem; - } - gap: 0.2rem; - min-width: 2.2rem; - width: fit-content; - - & + & { - margin-left: 0.2rem; - } - } - } - } -} - -.alert { - position: relative; - padding: 0.8rem 1.2rem; - line-height: 1.2; - margin-bottom: 1.2rem; - background-color: var(--color-alert-neutralBackground); - border-left: 0.3rem solid var(--color-alert-neutralBorder); - color: var(--color-alert-foreground); - - &__title { - font-size: 1.4rem; - margin: 0; - } - - &__description { - font-size: 1.2rem; - margin: 0.4rem 0 0; - } - - &__close { - position: absolute; - top: 0.8rem; - right: 0.8rem; - color: inherit; - opacity: 0.64; - - &:hover { - color: inherit; - opacity: 1; - } - } - &.is-collapsed { - cursor: pointer; - &:hover { - background-color: var(--color-alert-neutralHoverBackground); - } - } - - &.is-collapsed &__description, - &.is-collapsed &__close gl-tooltip:first-child, - &:not(.is-collapsed) &__close gl-tooltip:last-child { - display: none; - } - - &--info { - background-color: var(--color-alert-infoBackground); - border-left-color: var(--color-alert-infoBorder); - } - - &--warning { - background-color: var(--color-alert-warningBackground); - border-left-color: var(--color-alert-warningBorder); - } - - &--danger { - background-color: var(--color-alert-errorBackground); - border-left-color: var(--color-alert-errorBorder); - } -} - -gl-button.is-basic { - max-width: 300px; - width: 100%; - - & + & { - margin-top: 1rem; - } -} - -.mb-0 { - margin-bottom: 0; -} - -@media (max-width: 280px) { - .not-small { - display: none; - } -} -@media (min-width: 281px) { - .only-small { - display: none; - } -} - -.t { - &-eyebrow { - text-transform: uppercase; - font-size: 1rem; - font-weight: 600; - color: var(--color-foreground--50); - margin: 0; - } - - &-subtle { - color: var(--color-foreground--50); - } -} - -.nav-list { - margin: { - left: -2rem; - right: -2rem; - } - display: flex; - flex-direction: column; - gap: 0.1rem; - align-items: stretch; - margin-bottom: 1.6rem; - - &__item { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.8rem; - padding: 0.4rem 2rem; - - &:hover, - &:focus-within { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-list-hoverForeground); - } - - &:has(:first-child:focus) { - @include utils.focus(); - } - - &:has(:active) { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); - } - } - - &__link { - flex: 1; - display: flex; - flex-direction: row; - align-items: center; - gap: 0.8rem; - color: inherit; - - &:hover, - &:focus { - color: inherit; - text-decoration: none; - } - - &:focus { - outline: none; - } - } - - &__icon { - flex: none; - opacity: 0.5; - } - - &__label { - flex: 1; - font-weight: 600; - } - - &__desc { - color: var(--color-foreground--65); - font-variant: all-small-caps; - margin-left: 1rem; - } - - &__group { - width: 100%; - display: flex; - justify-content: flex-start; - } - - &__group &__label { - width: auto; - } - - &__access { - flex: none; - position: relative; - left: 1.5rem; - font-size: x-small; - outline: none; - white-space: nowrap; - - --gl-feature-badge-color: color-mix(in srgb, transparent 40%, currentColor); - --gl-feature-badge-border-color: color-mix(in srgb, transparent 40%, var(--color-foreground--50)); - } - - &__item:hover &__label { - text-decoration: underline; - } - - &__item:hover &__desc { - color: var(--color-foreground); - } - - &__item:focus-within &__access, - &__item:hover &__access { - --gl-feature-badge-color: currentColor; - --gl-feature-badge-border-color: var(--color-foreground--50); - } - - &__title { - padding: 0 2rem; - } -} - -.button-container { - margin: 1rem auto 0; - text-align: left; - max-width: 30rem; - transition: max-width 0.2s ease-out; -} - -@media (min-width: 640px) { - .button-container { - max-width: 100%; - } -} - -.button-container--trio { - > gl-button:first-child { - margin-bottom: 0.4rem; - } -} - -.button-group { - display: inline-flex; - gap: 0.4rem; - - &--single { - width: 100%; - max-width: 30rem; - } - - gl-button { - margin-top: 0; - - &:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - &:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - } -} - -.t-eyebrow.sticky { - top: -8px; -} +@include scrollbars.scrollbarFix(); diff --git a/src/webviews/apps/home/home.ts b/src/webviews/apps/home/home.ts index 633cbf96866be..7c475e9932283 100644 --- a/src/webviews/apps/home/home.ts +++ b/src/webviews/apps/home/home.ts @@ -1,229 +1,44 @@ /*global*/ import './home.scss'; -import type { Disposable } from 'vscode'; -import { getApplicablePromo } from '../../../plus/gk/account/promos'; +import { html } from 'lit'; +import { customElement } from 'lit/decorators.js'; import type { State } from '../../home/protocol'; -import { - CollapseSectionCommand, - DidChangeIntegrationsConnections, - DidChangeOrgSettings, - DidChangeRepositories, - DidChangeSubscription, -} from '../../home/protocol'; -import type { IpcMessage } from '../../protocol'; -import { ExecuteCommand } from '../../protocol'; -import { App } from '../shared/appBase'; -import type { GlFeatureBadge } from '../shared/components/feature-badge'; -import type { GlPromo } from '../shared/components/promo'; -import { DOM } from '../shared/dom'; -import '../shared/components/button'; -import '../shared/components/code-icon'; -import '../shared/components/feature-badge'; -import '../shared/components/overlays/tooltip'; -import '../shared/components/promo'; - -export class HomeApp extends App { - constructor() { - super('HomeApp'); - } - - private get blockRepoFeatures() { - const { - repositories: { openCount, hasUnsafe, trusted }, - } = this.state; - return !trusted || openCount === 0 || hasUnsafe; - } - - protected override onInitialize() { - this.state = this.getState() ?? this.state; - this.updateState(); - } - - protected override onBind(): Disposable[] { - const disposables = super.onBind?.() ?? []; - - disposables.push( - DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onDataActionClicked(e, target)), - DOM.on('[data-requires="repo"]', 'click', (e, target: HTMLElement) => this.onRepoFeatureClicked(e, target)), - DOM.on('[data-section-toggle]', 'click', (e, target: HTMLElement) => - this.onSectionToggleClicked(e, target), - ), - DOM.on('[data-section-expand]', 'click', (e, target: HTMLElement) => - this.onSectionExpandClicked(e, target), - ), - ); - - return disposables; - } - - protected override onMessageReceived(msg: IpcMessage) { - switch (true) { - case DidChangeRepositories.is(msg): - this.state.repositories = msg.params; - this.state.timestamp = Date.now(); - this.setState(this.state); - this.updateNoRepo(); - break; - - case DidChangeSubscription.is(msg): - this.state.subscription = msg.params.subscription; - this.setState(this.state); - this.updatePromos(); - this.updateSourceAndSubscription(); - - break; - - case DidChangeOrgSettings.is(msg): - this.state.orgSettings = msg.params.orgSettings; - this.setState(this.state); - this.updateOrgSettings(); - break; - - case DidChangeIntegrationsConnections.is(msg): - this.state.hasAnyIntegrationConnected = msg.params.hasAnyIntegrationConnected; - this.setState(this.state); - this.updateIntegrations(); - break; - - default: - super.onMessageReceived?.(msg); - break; - } - } - - private onRepoFeatureClicked(e: MouseEvent, _target: HTMLElement) { - if (this.blockRepoFeatures) { - e.preventDefault(); - e.stopPropagation(); - return false; - } - - return true; - } - - private onDataActionClicked(_e: MouseEvent, target: HTMLElement) { - const action = target.dataset.action; - this.onActionClickedCore(action); - } - - private onActionClickedCore(action?: string) { - if (action?.startsWith('command:')) { - this.sendCommand(ExecuteCommand, { command: action.slice(8) }); - } - } - - private onSectionToggleClicked(e: MouseEvent, target: HTMLElement) { - e.stopImmediatePropagation(); - const section = target.dataset.sectionToggle; - if (section !== 'walkthrough') { - return; - } - - this.updateCollapsedSections(!this.state.walkthroughCollapsed); - } - - private onSectionExpandClicked(_e: MouseEvent, target: HTMLElement) { - const section = target.dataset.sectionExpand; - if (section !== 'walkthrough') { - return; - } - this.updateCollapsedSections(false); - } - - private updateNoRepo() { - const { - repositories: { openCount, hasUnsafe, trusted }, - } = this.state; - - const header = document.getElementById('header')!; - if (!trusted) { - header.hidden = false; - setElementVisibility('untrusted-alert', true); - setElementVisibility('no-repo-alert', false); - setElementVisibility('unsafe-repo-alert', false); - - return; - } - - setElementVisibility('untrusted-alert', false); - - const noRepos = openCount === 0; - setElementVisibility('no-repo-alert', noRepos && !hasUnsafe); - setElementVisibility('unsafe-repo-alert', hasUnsafe); - header.hidden = !noRepos && !hasUnsafe; - } - - private updatePromos() { - const promo = getApplicablePromo(this.state.subscription.state); - - const $promo = document.getElementById('promo') as GlPromo; - $promo.promo = promo; - } - - private updateOrgSettings() { - const { - orgSettings: { drafts }, - } = this.state; - - for (const el of document.querySelectorAll('[data-org-requires="drafts"]')) { - setElementVisibility(el, drafts); - } - } - - private updateSourceAndSubscription() { - const { subscription } = this.state; - const els = document.querySelectorAll('gl-feature-badge'); - for (const el of els) { - el.source = { source: 'home', detail: 'badge' }; - el.subscription = subscription; - } - } - - private updateCollapsedSections(toggle = this.state.walkthroughCollapsed) { - this.state.walkthroughCollapsed = toggle; - this.setState({ walkthroughCollapsed: toggle }); - document.getElementById('section-walkthrough')!.classList.toggle('is-collapsed', toggle); - this.sendCommand(CollapseSectionCommand, { - section: 'walkthrough', - collapsed: toggle, - }); - } - - private updateIntegrations() { - const { hasAnyIntegrationConnected } = this.state; - const els = document.querySelectorAll('[data-integrations]'); - const dataValue = hasAnyIntegrationConnected ? 'connected' : 'none'; - for (const el of els) { - setElementVisibility(el, el.dataset.integrations === dataValue); - } - } - - private updateState() { - this.updateNoRepo(); - this.updatePromos(); - this.updateSourceAndSubscription(); - this.updateOrgSettings(); - this.updateCollapsedSections(); - this.updateIntegrations(); +import { GlApp } from '../shared/app'; +import { scrollableBase } from '../shared/components/styles/lit/base.css'; +import type { HostIpc } from '../shared/ipc'; +import { homeBaseStyles, homeStyles } from './home.css'; +import { HomeStateProvider } from './stateProvider'; +import '../plus/shared/components/home-account-content'; +import './components/feature-nav'; +import './components/home-nav'; +import './components/repo-alerts'; +import './components/onboarding'; + +@customElement('gl-home-app') +export class GlHomeApp extends GlApp { + static override styles = [homeBaseStyles, scrollableBase, homeStyles]; + + private badgeSource = { source: 'home', detail: 'badge' }; + + protected override createStateProvider(state: State, ipc: HostIpc) { + return new HomeStateProvider(this, state, ipc); + } + + override render() { + return html` +
+ +
+ + +
+ +
+ + + +
+
+ `; } } - -function setElementVisibility(elementOrId: string | HTMLElement | null | undefined, visible: boolean) { - let el; - if (typeof elementOrId === 'string') { - el = document.getElementById(elementOrId); - } else { - el = elementOrId; - } - if (el == null) return; - - if (visible) { - el.removeAttribute('aria-hidden'); - el.removeAttribute('hidden'); - } else { - el.setAttribute('aria-hidden', ''); - el?.setAttribute('hidden', ''); - } -} - -new HomeApp(); diff --git a/src/webviews/apps/home/stateProvider.ts b/src/webviews/apps/home/stateProvider.ts new file mode 100644 index 0000000000000..eb60570b34174 --- /dev/null +++ b/src/webviews/apps/home/stateProvider.ts @@ -0,0 +1,65 @@ +import { ContextProvider } from '@lit/context'; +import type { ReactiveControllerHost } from 'lit'; +import type { State } from '../../home/protocol'; +import { + DidChangeIntegrationsConnections, + DidChangeOrgSettings, + DidChangeRepositories, + DidChangeSubscription, +} from '../../home/protocol'; +import type { Disposable } from '../shared/events'; +import type { HostIpc } from '../shared/ipc'; +import { stateContext } from './context'; + +type ReactiveElementHost = Partial & HTMLElement; + +export class HomeStateProvider implements Disposable { + private readonly disposable: Disposable; + private readonly provider: ContextProvider<{ __context__: State }, ReactiveElementHost>; + private readonly state: State; + + constructor( + host: ReactiveElementHost, + state: State, + private readonly _ipc: HostIpc, + ) { + this.state = state; + this.provider = new ContextProvider(host, { context: stateContext, initialValue: state }); + + this.disposable = this._ipc.onReceiveMessage(msg => { + switch (true) { + case DidChangeRepositories.is(msg): + this.state.repositories = msg.params; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + case DidChangeSubscription.is(msg): + this.state.subscription = msg.params.subscription; + this.state.avatar = msg.params.avatar; + this.state.organizationsCount = msg.params.organizationsCount; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + case DidChangeOrgSettings.is(msg): + this.state.orgSettings = msg.params.orgSettings; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + + case DidChangeIntegrationsConnections.is(msg): + this.state.hasAnyIntegrationConnected = msg.params.hasAnyIntegrationConnected; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + } + }); + } + + dispose() { + this.disposable.dispose(); + } +} diff --git a/src/webviews/apps/plus/shared/components/home-account-content.ts b/src/webviews/apps/plus/shared/components/home-account-content.ts new file mode 100644 index 0000000000000..b00a3df3878aa --- /dev/null +++ b/src/webviews/apps/plus/shared/components/home-account-content.ts @@ -0,0 +1,385 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import { urls } from '../../../../../constants'; +import type { Promo } from '../../../../../plus/gk/account/promos'; +import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; +import { + getSubscriptionPlanName, + getSubscriptionTimeRemaining, + hasAccountFromSubscriptionState, + SubscriptionPlanId, + SubscriptionState, +} from '../../../../../plus/gk/account/subscription'; +import { pluralize } from '../../../../../system/string'; +import type { State } from '../../../../home/protocol'; +import { stateContext } from '../../../home/context'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/promo'; +import '../../../shared/components/accordion/accordion'; + +@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; + } + + :host > * { + margin-bottom: 0; + } + + button-container { + margin-bottom: 1.3rem; + } + + .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%; + } + + .account > :first-child { + margin-block-start: 0; + } + .account > :last-child { + margin-block-end: 0; + } + `, + ]; + + @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.FreePlusInTrial && + (this._state.subscription?.plan.effective.trialReactivationCount ?? 0) > 0 + ); + } + + private get planId() { + return this._state.subscription?.plan.actual.id ?? SubscriptionPlanId.Pro; + } + + get planName() { + switch (this.state) { + case SubscriptionState.Free: + case SubscriptionState.FreePreviewTrialExpired: + case SubscriptionState.FreePlusTrialExpired: + case SubscriptionState.FreePlusTrialReactivationEligible: + return 'GitKraken Free'; + case SubscriptionState.FreeInPreviewTrial: + case SubscriptionState.FreePlusInTrial: + return 'GitKraken Pro (Trial)'; + case SubscriptionState.VerificationRequired: + return `${getSubscriptionPlanName(this.planId)} (Unverified)`; + default: + return getSubscriptionPlanName(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.hasAccount, + () => 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} + +
`, + )} +
+ `; + } + + private renderAccountState() { + const promo = getApplicablePromo(this.state); + + switch (this.state) { + case SubscriptionState.Paid: + return html` + + `; + + case SubscriptionState.VerificationRequired: + return html` + + `; + + case SubscriptionState.FreePlusInTrial: { + const days = this.daysRemaining; + + return html` + + `; + } + + case SubscriptionState.FreePlusTrialExpired: + return html` + + `; + + case SubscriptionState.FreePlusTrialReactivationEligible: + return html` + + `; + + default: + return html` + + `; + } + } + + private renderIncludesDevEx() { + return html` +

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

+ `; + } + + private renderPromo(promo: Promo | undefined) { + return html``; + } +} diff --git a/src/webviews/apps/shared/app.ts b/src/webviews/apps/shared/app.ts index 7b0715c149910..2eba513249a9e 100644 --- a/src/webviews/apps/shared/app.ts +++ b/src/webviews/apps/shared/app.ts @@ -38,6 +38,9 @@ export abstract class GlApp< private _focused?: boolean; private _inputFocused?: boolean; private _sendWebviewFocusChangedCommandDebounced!: Deferrable<(params: WebviewFocusChangedParams) => void>; + private _stateProvider!: Disposable; + + protected abstract createStateProvider(state: State, ipc: HostIpc): Disposable; override connectedCallback() { super.connectedCallback(); @@ -53,6 +56,7 @@ export abstract class GlApp< this._ipc = new HostIpc(this.name); this.disposables.push( + (this._stateProvider = this.createStateProvider(this.state, this._ipc)), this._ipc.onReceiveMessage(msg => { switch (true) { case DidChangeWebviewFocusNotification.is(msg): diff --git a/src/webviews/apps/shared/components/accordion/accordion.ts b/src/webviews/apps/shared/components/accordion/accordion.ts new file mode 100644 index 0000000000000..b3aa13c0488d2 --- /dev/null +++ b/src/webviews/apps/shared/components/accordion/accordion.ts @@ -0,0 +1,111 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +const accordionTagName = 'gl-accordion'; + +@customElement(accordionTagName) +export class GlAccordion extends LitElement { + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = css` + :host { + display: block; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + font-weight: var(--vscode-font-weight); + background-color: var(--vscode-editor-background); + color: var(--vscode-foreground); + } + + /* + details { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + overflow: hidden; + } + */ + + .header { + padding: 8px 12px; + background-color: var(--vscode-sideBar-background); + cursor: pointer; + user-select: none; + list-style: none; + outline: none; + display: flex; + align-items: center; + gap: 0.6rem; + } + + .header::-webkit-details-marker { + display: none; + } + + .label { + flex: 1; + display: block; + } + + .icon { + flex: none; + width: 20px; + color: var(--vscode-foreground); + opacity: 0.6; + } + + .header:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .header:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .content { + padding: 12px; + background-color: var(--vscode-editor-background); + } + `; + + @property({ type: Boolean }) open = false; + + get headerId() { + return `gl-accordion-header-${this.id ?? Math.random().toString(36).substring(2, 9)}`; + } + + override render() { + return html` +
+ + + + +
+ +
+
+ `; + } + + private _handleToggle(e: Event) { + const details = e.target as HTMLDetailsElement; + this.open = details.open; + this.dispatchEvent( + new CustomEvent('gl-toggle', { + detail: { open: this.open }, + bubbles: true, + composed: true, + }), + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + [accordionTagName]: GlAccordion; + } +} diff --git a/src/webviews/apps/shared/components/button.ts b/src/webviews/apps/shared/components/button.ts index f567a89ddea74..8a6d1e5b46400 100644 --- a/src/webviews/apps/shared/components/button.ts +++ b/src/webviews/apps/shared/components/button.ts @@ -48,6 +48,7 @@ export class GlButton extends LitElement { } .control { + box-sizing: border-box; display: inline-flex; flex-direction: row; justify-content: center; diff --git a/src/webviews/apps/shared/components/styles/lit/base.css.ts b/src/webviews/apps/shared/components/styles/lit/base.css.ts index a211ce1ce6849..f7a855663ff42 100644 --- a/src/webviews/apps/shared/components/styles/lit/base.css.ts +++ b/src/webviews/apps/shared/components/styles/lit/base.css.ts @@ -61,4 +61,8 @@ export const scrollableBase = css` border-color: var(--vscode-scrollbarSlider-background); transition: none; } + + :host-context(.preload) .scrollable { + transition: none; + } `; diff --git a/src/webviews/apps/shared/styles/scrollbars.scss b/src/webviews/apps/shared/styles/scrollbars.scss index debc51d0b47df..0bf75b1f0a225 100644 --- a/src/webviews/apps/shared/styles/scrollbars.scss +++ b/src/webviews/apps/shared/styles/scrollbars.scss @@ -1,10 +1,14 @@ -@mixin scrollableBase() { +@mixin scrollbarFix() { // This @supports selector is a temporary fix for https://github.com/microsoft/vscode/issues/213045#issuecomment-2211442905 @supports selector(::-webkit-scrollbar) { html { scrollbar-color: unset; } } +} + +@mixin scrollableBase() { + @include scrollbarFix(); body { &.scrollable, diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index af16639a7b436..ebbe5c0546e3b 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -1,4 +1,5 @@ import { Disposable, workspace } from 'vscode'; +import { getAvatarUriFromGravatarEmail } from '../../avatars'; import type { ContextKeys } from '../../constants.context'; import type { Container } from '../../container'; import type { Subscription } from '../../plus/gk/account/subscription'; @@ -117,12 +118,15 @@ export class HomeWebviewProvider implements WebviewProvider { } private async getState(subscription?: Subscription): Promise { - subscription ??= await this.container.subscription.getSubscription(true); + const subResult = await this.getSubscription(subscription); + return { ...this.host.baseWebviewState, repositories: this.getRepositoriesState(), webroot: this.host.getWebRoot(), - subscription: subscription, + subscription: subResult.subscription, + avatar: subResult.avatar, + organizationsCount: subResult.organizationsCount, orgSettings: this.getOrgSettings(), walkthroughCollapsed: this.getWalkthroughCollapsed(), hasAnyIntegrationConnected: this.isAnyIntegrationConnected(), @@ -150,6 +154,24 @@ export class HomeWebviewProvider implements WebviewProvider { return this._hostedIntegrationConnected; } + private async getSubscription(subscription?: Subscription) { + subscription ??= await this.container.subscription.getSubscription(true); + + let avatar; + if (subscription.account?.email) { + avatar = getAvatarUriFromGravatarEmail(subscription.account.email, 34).toString(); + } else { + avatar = `${this.host.getWebRoot() ?? ''}/media/gitlens-logo.webp`; + } + + return { + subscription: subscription, + avatar: avatar, + organizationsCount: + subscription != null ? ((await this.container.organizations.getOrganizations()) ?? []).length : 0, + }; + } + private notifyDidChangeRepositories() { void this.host.notify(DidChangeRepositories, this.getRepositoriesState()); } @@ -163,10 +185,12 @@ export class HomeWebviewProvider implements WebviewProvider { } private async notifyDidChangeSubscription(subscription?: Subscription) { - subscription ??= await this.container.subscription.getSubscription(true); + const subResult = await this.getSubscription(subscription); void this.host.notify(DidChangeSubscription, { - subscription: subscription, + subscription: subResult.subscription, + avatar: subResult.avatar, + organizationsCount: subResult.organizationsCount, }); } diff --git a/src/webviews/home/protocol.ts b/src/webviews/home/protocol.ts index 3668324ed607b..8923a8af81114 100644 --- a/src/webviews/home/protocol.ts +++ b/src/webviews/home/protocol.ts @@ -13,6 +13,8 @@ export interface State extends WebviewState { }; walkthroughCollapsed: boolean; hasAnyIntegrationConnected: boolean; + avatar?: string; + organizationsCount?: number; } // COMMANDS @@ -43,6 +45,8 @@ export const DidChangeIntegrationsConnections = new IpcNotification(scope, 'subscription/didChange');