Browse Source

Updates focus view

- adds subscription state
- adds loading and empty states
- adds account access badge
- moves filters to each section
main
Keith Daulton 1 year ago
committed by Keith Daulton
parent
commit
684122b034
8 changed files with 602 additions and 138 deletions
  1. +1
    -0
      src/features.ts
  2. +19
    -1
      src/plus/webviews/workspaces/protocol.ts
  3. +136
    -63
      src/plus/webviews/workspaces/workspacesWebview.ts
  4. +4
    -6
      src/webviews/apps/plus/workspaces/components/pull-request-row.ts
  5. +63
    -32
      src/webviews/apps/plus/workspaces/workspaces.html
  6. +113
    -4
      src/webviews/apps/plus/workspaces/workspaces.scss
  7. +121
    -32
      src/webviews/apps/plus/workspaces/workspaces.ts
  8. +145
    -0
      src/webviews/apps/shared/components/account/account-badge.ts

+ 1
- 0
src/features.ts View File

@ -35,4 +35,5 @@ export const enum PlusFeatures {
Timeline = 'timeline',
Worktrees = 'worktrees',
Graph = 'graph',
Focus = 'focus',
}

+ 19
- 1
src/plus/webviews/workspaces/protocol.ts View File

@ -1,11 +1,14 @@
import type { IssueShape } from '../../../git/models/issue';
import type { PullRequestShape } from '../../../git/models/pullRequest';
import type { Subscription } from '../../../subscription';
import { IpcNotificationType } from '../../../webviews/protocol';
export type State = {
isPlus: boolean;
subscription: Subscription;
pullRequests?: PullRequestResult[];
issues?: IssueResult[];
repos?: IssueResult[];
repos?: RepoWithRichProvider[];
[key: string]: unknown;
};
@ -21,6 +24,12 @@ export interface PullRequestResult extends SearchResultBase {
pullRequest: PullRequestShape;
}
export interface RepoWithRichProvider {
repo: string;
isGitHub: boolean;
isConnected: boolean;
}
export interface DidChangeStateNotificationParams {
state: State;
}
@ -29,3 +38,12 @@ export const DidChangeStateNotificationType = new IpcNotificationType
'focus/state/didChange',
true,
);
export interface DidChangeSubscriptionParams {
subscription: Subscription;
isPlus: boolean;
}
export const DidChangeSubscriptionNotificationType = new IpcNotificationType<DidChangeSubscriptionParams>(
'graph/subscription/didChange',
true,
);

+ 136
- 63
src/plus/webviews/workspaces/workspacesWebview.ts View File

@ -2,6 +2,7 @@ import type { Disposable } from 'vscode';
import { Commands, ContextKeys } from '../../../constants';
import type { Container } from '../../../container';
import { setContext } from '../../../context';
import { PlusFeatures } from '../../../features';
import type { SearchedIssue } from '../../../git/models/issue';
import { serializeIssue } from '../../../git/models/issue';
import type { SearchedPullRequest } from '../../../git/models/pullRequest';
@ -13,15 +14,27 @@ import {
import type { GitRemote } from '../../../git/models/remote';
import type { Repository } from '../../../git/models/repository';
import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider';
import type { Subscription } from '../../../subscription';
import { SubscriptionState } from '../../../subscription';
import { registerCommand } from '../../../system/command';
import { WebviewBase } from '../../../webviews/webviewBase';
import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService';
import { ensurePlusFeaturesEnabled } from '../../subscription/utils';
import type { State } from './protocol';
import { DidChangeStateNotificationType } from './protocol';
import { DidChangeStateNotificationType, DidChangeSubscriptionNotificationType } from './protocol';
interface RepoWithRichRemote {
repo: Repository;
remote: GitRemote<RichRemoteProvider>;
isConnected: boolean;
isGitHub: boolean;
}
export class WorkspacesWebview extends WebviewBase<State> {
private _bootstrapping = true;
private _pullRequests: SearchedPullRequest[] = [];
private _issues: SearchedIssue[] = [];
private _etagSubscription?: number;
constructor(container: Container) {
super(
@ -34,6 +47,8 @@ export class WorkspacesWebview extends WebviewBase {
'workspacesWebview',
Commands.ShowWorkspacesPage,
);
this.disposables.push(this.container.subscription.onDidChange(this.onSubscriptionChanged, this));
}
protected override registerCommands(): Disposable[] {
@ -51,6 +66,22 @@ export class WorkspacesWebview extends WebviewBase {
void setContext(ContextKeys.WorkspacesFocused, focused);
}
private async onSubscriptionChanged(e: SubscriptionChangeEvent) {
if (e.etag === this._etagSubscription) return;
this._etagSubscription = e.etag;
const access = await this.container.git.access(PlusFeatures.Focus);
const { subscription, isPlus } = await this.getSubscription(access.subscription.current);
if (isPlus) {
void this.notifyDidChangeState();
}
return this.notify(DidChangeSubscriptionNotificationType, {
subscription: subscription,
isPlus: isPlus,
});
}
private async getWorkspaces() {
try {
const rsp = await this.container.workspaces.getWorkspacesWithPullRequests();
@ -62,116 +93,154 @@ export class WorkspacesWebview extends WebviewBase {
return {};
}
private async getSubscription(subscription?: Subscription) {
const currentSubscription = subscription ?? (await this.container.subscription.getSubscription(true));
const isPlus = ![
SubscriptionState.Free,
SubscriptionState.FreePreviewTrialExpired,
SubscriptionState.FreePlusTrialExpired,
SubscriptionState.VerificationRequired,
].includes(currentSubscription.state);
return {
subscription: currentSubscription,
isPlus: isPlus,
};
}
private async getState(deferState = false): Promise<State> {
const prs = await this.getMyPullRequests();
const { subscription, isPlus } = await this.getSubscription();
if (deferState || !isPlus) {
return {
isPlus: isPlus,
subscription: subscription,
};
}
const richRepos = await this.getRichRepos();
const prs = await this.getMyPullRequests(richRepos);
const serializedPrs = prs.map(pr => ({
pullRequest: serializePullRequest(pr.pullRequest),
reasons: pr.reasons,
}));
const issues = await this.getMyIssues();
const issues = await this.getMyIssues(richRepos);
const serializedIssues = issues.map(issue => ({
issue: serializeIssue(issue.issue),
reasons: issue.reasons,
}));
return {
// workspaces: await this.getWorkspaces(),
isPlus: isPlus,
subscription: subscription,
pullRequests: serializedPrs,
issues: serializedIssues,
};
}
protected override async includeBootstrap(): Promise<State> {
if (this._bootstrapping) {
const state = await this.getState(true);
if (state.isPlus) {
void this.notifyDidChangeState();
}
return state;
}
return this.getState();
}
private async getRichRepos(): Promise<{ repo: Repository; provider: GitRemote<RichRemoteProvider> }[]> {
const repos: { repo: Repository; provider: GitRemote<RichRemoteProvider> }[] = [];
private async getRichRepos(): Promise<RepoWithRichRemote[]> {
const repos = [];
for (const repo of this.container.git.openRepositories) {
const richRemote = await repo.getRichRemote(true);
if (richRemote == null || repos.findIndex(repo => repo.provider === richRemote) > -1) {
if (richRemote == null || repos.findIndex(repo => repo.remote === richRemote) > -1) {
continue;
}
repos.push({
repo: repo,
provider: richRemote,
remote: richRemote,
isConnected: await richRemote.provider.isConnected(),
isGitHub: richRemote.provider.name === 'GitHub',
});
}
return repos;
}
private async getMyPullRequests(): Promise<SearchedPullRequest[]> {
if (this._pullRequests.length === 0) {
const richRepos = await this.getRichRepos();
const allPrs = [];
for (const { provider } of richRepos) {
const prs = await this.container.git.getMyPullRequests(provider);
if (prs == null) {
continue;
}
allPrs.push(...prs.filter(pr => pr.reasons.length > 0));
private async getMyPullRequests(richReposWithRemote?: RepoWithRichRemote[]): Promise<SearchedPullRequest[]> {
// if (this._pullRequests.length === 0) {
const richRepos = richReposWithRemote ?? (await this.getRichRepos());
const allPrs = [];
for (const { remote } of richRepos) {
const prs = await this.container.git.getMyPullRequests(remote);
if (prs == null) {
continue;
}
allPrs.push(...prs.filter(pr => pr.reasons.length > 0));
}
function getScore(pr: SearchedPullRequest) {
let score = 0;
if (pr.reasons.includes('author')) {
score += 1000;
} else if (pr.reasons.includes('assignee')) {
score += 900;
} else if (pr.reasons.includes('reviewer')) {
score += 800;
} else if (pr.reasons.includes('mentioned')) {
score += 700;
}
function getScore(pr: SearchedPullRequest) {
let score = 0;
if (pr.reasons.includes('authored')) {
score += 1000;
} else if (pr.reasons.includes('assigned')) {
score += 900;
} else if (pr.reasons.includes('review-requested')) {
score += 800;
} else if (pr.reasons.includes('mentioned')) {
score += 700;
}
if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) {
if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) {
score += 100;
} else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) {
score += 90;
} else {
score += 80;
}
} else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) {
score += 70;
if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) {
if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) {
score += 100;
} else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) {
score += 90;
} else {
score += 80;
}
return score;
} else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) {
score += 70;
}
this._pullRequests = allPrs.sort((a, b) => {
const scoreA = getScore(a);
const scoreB = getScore(b);
if (scoreA === scoreB) {
return a.pullRequest.date.getTime() - b.pullRequest.date.getTime();
}
return (scoreB ?? 0) - (scoreA ?? 0);
});
return score;
}
this._pullRequests = allPrs.sort((a, b) => {
const scoreA = getScore(a);
const scoreB = getScore(b);
if (scoreA === scoreB) {
return a.pullRequest.date.getTime() - b.pullRequest.date.getTime();
}
return (scoreB ?? 0) - (scoreA ?? 0);
});
// }
return this._pullRequests;
}
private async getMyIssues(): Promise<SearchedIssue[]> {
if (this._issues.length === 0) {
const richRepos = await this.getRichRepos();
const allIssues = [];
for (const { provider } of richRepos) {
const issues = await this.container.git.getMyIssues(provider);
if (issues == null) {
continue;
}
allIssues.push(...issues.filter(pr => pr.reasons.length > 0));
private async getMyIssues(richReposWithRemote?: RepoWithRichRemote[]): Promise<SearchedIssue[]> {
// if (this._issues.length === 0) {
const richRepos = richReposWithRemote ?? (await this.getRichRepos());
const allIssues = [];
for (const { remote } of richRepos) {
const issues = await this.container.git.getMyIssues(remote);
if (issues == null) {
continue;
}
this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime());
allIssues.push(...issues.filter(pr => pr.reasons.length > 0));
}
this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime());
// }
return this._issues;
}
override async show(options?: {
preserveFocus?: boolean | undefined;
preserveVisibility?: boolean | undefined;
@ -182,6 +251,10 @@ export class WorkspacesWebview extends WebviewBase {
}
private async notifyDidChangeState() {
return this.notify(DidChangeStateNotificationType, { state: await this.getState() });
if (!this.visible) return;
const state = await this.getState();
this._bootstrapping = false;
void this.notify(DidChangeStateNotificationType, { state: state });
}
}

+ 4
- 6
src/webviews/apps/plus/workspaces/components/pull-request-row.ts View File

@ -37,6 +37,7 @@ const template = html`
title="cannot be merged due to merge conflicts"
></code-icon>`,
)}
${when(x => x.indicator === 'checks', html`<code-icon icon="error" title="checks failed"></code-icon>`)}
</table-cell>
<table-cell class="time">${x => x.lastUpdated}</table-cell>
<table-cell>
@ -84,11 +85,6 @@ const template = html`
<git-avatars :avatars="${x => x.pullRequest!.assignees}"></git-avatars>
</table-cell>
<table-cell>${x => x.pullRequest!.comments}</table-cell>
<table-cell class="icon-only">
${when(x => x.checks == null, html`<code-icon icon="dash" title="none"></code-icon>`)}
${when(x => x.checks === false, html`<code-icon icon="error" title="failed"></code-icon>`)}
${when(x => x.checks === true, html`<code-icon icon="pass" title="passed"></code-icon>`)}
</table-cell>
<table-cell class="stats"
><span class="stat-added">+${x => x.pullRequest!.additions}</span>
<span class="stat-deleted">-${x => x.pullRequest!.deletions}</span></table-cell
@ -229,7 +225,9 @@ export class PullRequestRow extends FASTElement {
if (this.pullRequest == null) return '';
console.log(this.pullRequest);
if (this.pullRequest.reviewDecision === 'ChangesRequested') {
if (this.checks === false) {
return 'checks';
} else if (this.pullRequest.reviewDecision === 'ChangesRequested') {
return 'changes';
} else if (this.pullRequest.reviewDecision === 'Approved' && this.pullRequest.mergeableState === 'Mergeable') {
return 'ready';

+ 63
- 32
src/webviews/apps/plus/workspaces/workspaces.html View File

@ -5,15 +5,25 @@
</head>
<body class="preload app">
<header class="app__header" style="display: none">
<h1>Focus View</h1>
<header class="app__header" id="header">
<account-badge id="account-badge"></account-badge>
</header>
<div class="app__content">
<div class="app__content" id="content">
<div class="app__cover"></div>
<main class="app__main">
<section class="workspace-section app__section">
<header class="workspace-section__header">
<h2>My Pull Requests</h2>
<nav class="tab-filter" id="pr-filter">
<button class="tab-filter__tab is-active" type="button" data-tab="">All</button>
<button class="tab-filter__tab" type="button" data-tab="authored">Opened by Me</button>
<button class="tab-filter__tab" type="button" data-tab="assigned">Assigned to Me</button>
<button class="tab-filter__tab" type="button" data-tab="review-requested">
Needs my Review
</button>
<button class="tab-filter__tab" type="button" data-tab="mentioned">Mentions Me</button>
</nav>
</header>
<div class="workspace-section__content">
<table-container id="pull-requests">
@ -30,9 +40,6 @@
<table-cell class="pr-comments" header="column" pinned title="Comments">
<code-icon icon="comment-discussion"></code-icon>
</table-cell>
<table-cell class="pr-checks" header="column" pinned title="Checks">
<code-icon icon="tasklist"></code-icon>
</table-cell>
<table-cell class="pr-stats" header="column" pinned title="Change stats">
<code-icon icon="add"></code-icon>
<code-icon icon="dash"></code-icon>
@ -42,12 +49,26 @@
></table-cell>
</table-row>
</table-container>
<div class="alert" id="loading-pull-requests">
<span class="alert__content"
><code-icon modifier="spin" icon="loading"></code-icon> Loading</span
>
</div>
<div class="alert" id="no-pull-requests">
<span class="alert__content">No pull requests found</span>
</div>
</div>
</section>
<section class="workspace-section app__section">
<header class="workspace-section__header">
<h2>My Issues</h2>
<nav class="tab-filter" id="issue-filter">
<button class="tab-filter__tab is-active" type="button" data-tab="">All</button>
<button class="tab-filter__tab" type="button" data-tab="authored">Opened by Me</button>
<button class="tab-filter__tab" type="button" data-tab="assigned">Assigned to Me</button>
<button class="tab-filter__tab" type="button" data-tab="mentioned">Mentions Me</button>
</nav>
</header>
<div class="workspace-section__content">
<table-container id="issues">
@ -60,19 +81,30 @@
</table-cell>
<table-cell header="column" pinned>Title</table-cell>
<table-cell class="pr-author" header="column" pinned>Author</table-cell>
<table-cell class="pr-assigned" header="column" pinned>Assignees</table-cell>
<table-cell class="pr-assigned" header="column" pinned>Assigned</table-cell>
<table-cell class="pr-comments" header="column" pinned title="Comments">
<code-icon icon="comment-discussion"></code-icon>
</table-cell>
<table-cell class="pr-checks" header="column" pinned title="Thumbs up">
<code-icon icon="thumbsup"></code-icon>
</table-cell>
<table-cell class="pr-actions" header="column" pinned
><code-icon icon="blank" title="actions"></code-icon
></table-cell>
</table-row>
</table-container>
<div class="alert" id="loading-issues">
<span class="alert__content"
><code-icon modifier="spin" icon="loading"></code-icon> Loading</span
>
</div>
<div class="alert" id="no-issues" hidden>
<span class="alert__content">No issues found</span>
</div>
</div>
</section>
<section class="workspace-section app__section">
<!-- <section class="workspace-section app__section">
<header class="workspace-section__header">
<h2>Work in Progress</h2>
</header>
@ -103,25 +135,21 @@
</table-row>
</table-container>
</div>
</section>
</section> -->
</main>
<menu-list class="app__controls">
<!-- <menu-list class="app__controls">
<menu-label>Type</menu-label>
<menu-item role="none">
<div class="choice">
<input class="choice__input" type="checkbox" id="filter-prs" value="mentioned" checked />
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-prs">Pull requests</label>
</div>
</menu-item>
<menu-item role="none">
<div class="choice">
<input class="choice__input" type="checkbox" id="filter-issues" value="mentioned" checked />
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-issues">Issues</label>
</div>
</menu-item>
@ -130,18 +158,14 @@
<menu-item role="none">
<div class="choice">
<input class="choice__input" type="checkbox" id="filter-assigned" value="assigned" checked />
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-assigned">Assigned to me</label>
</div>
</menu-item>
<menu-item role="none">
<div class="choice">
<input class="choice__input" type="checkbox" id="filter-created" value="authored" checked />
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-created">Created by me</label>
</div>
</menu-item>
@ -154,18 +178,14 @@
value="review-requested"
checked
/>
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-review-requested">Review requested</label>
</div>
</menu-item>
<menu-item role="none">
<div class="choice">
<input class="choice__input" type="checkbox" id="filter-mentioned" value="mentioned" checked />
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-mentioned">Mentioned</label>
</div>
</menu-item>
@ -181,16 +201,27 @@
checked
readonly
/>
<span class="choice__indicator"
><code-icon class="choice__indicator" icon="check"></code-icon
></span>
<span class="choice__indicator"><code-icon icon="check"></code-icon></span>
<label class="choice__label" for="filter-repo-1">vscode-gitlens</label>
</div>
</menu-item>
</menu-list>
</menu-list> -->
</div>
<div class="overlay" id="overlay" hidden>
<div class="overlay__content">
The Focus view helps you focus on the work that matters most. It shows you the pull requests and issues
that you are involved in, and hides everything else.
</div>
</div>
#{endOfBody}
<style nonce="#{cspNonce}">
:root {
--gl-plus-bg: url(#{webroot}/media/gitlens-backdrop.webp);
}
</style>
<style nonce="#{cspNonce}">
@font-face {
font-family: 'codicon';
font-display: block;

+ 113
- 4
src/webviews/apps/plus/workspaces/workspaces.scss View File

@ -1,3 +1,8 @@
@mixin focusStyles() {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
:root {
--gitlens-z-inline: 1000;
--gitlens-z-sticky: 1100;
@ -26,6 +31,7 @@ body {
--background-15: var(--color-background--lighten-15);
--background-30: var(--color-background--lighten-30);
--background-50: var(--color-background--lighten-50);
--popover-bg: var(--color-background--lighten-15);
}
.vscode-high-contrast-light,
@ -37,6 +43,7 @@ body {
--background-15: var(--color-background--darken-15);
--background-30: var(--color-background--darken-30);
--background-50: var(--color-background--darken-50);
--popover-bg: var(--color-background--darken-15);
}
:root {
@ -71,10 +78,13 @@ a {
&:hover {
text-decoration: underline;
}
}
a,
button:not([disabled]),
[tabindex]:not([tabindex='-1']) {
&:focus {
outline: 1px solid var(--focus-color);
outline-offset: -1px;
@include focusStyles();
}
}
@ -119,6 +129,51 @@ code-icon {
background-color: var(--vscode-toolbar-hoverBackground);
}
.alert {
display: flex;
flex-direction: row;
padding: 0.8rem 1.2rem;
background-color: var(--color-alert-neutralBackground);
border-left: 0.3rem solid var(--color-foreground--50);
color: var(--color-alert-foreground);
code-icon {
margin-right: 0.4rem;
vertical-align: baseline;
}
&__content {
font-size: 1.2rem;
line-height: 1.2;
text-align: left;
}
}
.tab-filter {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 1rem;
&__tab {
padding: 0.2rem 0;
text-transform: uppercase;
color: var(--color-foreground--65);
border: none;
background: none;
text-align: center;
font-size: 1.1rem;
border-bottom: 0.1rem solid transparent;
cursor: pointer;
&.is-active {
color: var(--vscode-foreground);
border-bottom-color: var(--color-foreground);
}
}
}
.workspace-icon {
font-size: 1.6rem;
vertical-align: sub;
@ -130,6 +185,10 @@ code-icon {
&__header {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
&__content {
@ -144,13 +203,15 @@ code-icon {
flex-direction: column;
height: 100vh;
overflow: hidden;
padding-right: 0;
// padding-right: 0;
&__header {
flex: none;
text-align: right;
}
&__content {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: row;
@ -170,7 +231,20 @@ code-icon {
}
&__section {
min-height: 15rem;
flex: 0 1 auto;
flex: 0 1 50%;
}
&__cover {
[aria-hidden='true'] & {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
backdrop-filter: blur(4px) saturate(0.8);
z-index: var(--gitlens-z-cover);
pointer-events: none;
}
}
}
@ -285,3 +359,38 @@ code-icon {
cursor: pointer;
}
}
.overlay {
z-index: var(--gitlens-z-modal);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-size: 1.3em;
min-height: 100%;
padding: 0 2rem 2rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&__content {
max-width: 600px;
background: var(--color-hover-background) no-repeat left top;
background-image: var(--gl-plus-bg);
border: 1px solid var(--color-hover-border);
border-radius: 0.4rem;
margin: 1rem;
padding: 1.2rem;
> p:first-child {
margin-top: 0;
}
vscode-button:not([appearance='icon']) {
align-self: center !important;
}
}
}

+ 121
- 32
src/webviews/apps/plus/workspaces/workspaces.ts View File

@ -1,8 +1,13 @@
import type { State } from '../../../../plus/webviews/workspaces/protocol';
import { DidChangeStateNotificationType } from '../../../../plus/webviews/workspaces/protocol';
import {
DidChangeStateNotificationType,
DidChangeSubscriptionNotificationType,
} from '../../../../plus/webviews/workspaces/protocol';
import type { IpcMessage } from '../../../protocol';
import { onIpc } from '../../../protocol';
import { App } from '../../shared/appBase';
import type { AccountBadge } from '../../shared/components/account/account-badge';
import { DOM } from '../../shared/dom';
import type { IssueRow } from './components/issue-row';
import type { PullRequestRow } from './components/pull-request-row';
import '../../shared/components/code-icon';
@ -15,6 +20,7 @@ import '../../shared/components/menu/menu-divider';
import '../../shared/components/table/table-container';
import '../../shared/components/table/table-row';
import '../../shared/components/table/table-cell';
import '../../shared/components/account/account-badge';
import './components/issue-row';
import './components/pull-request-row';
import './workspaces.scss';
@ -24,12 +30,37 @@ export class WorkspacesApp extends App {
super('WorkspacesApp');
}
_prFilter?: string;
_issueFilter?: string;
override onInitialize() {
this.log(`${this.appName}.onInitialize`);
this.renderContent();
console.log(this.state);
}
protected override onBind() {
const disposables = super.onBind?.() ?? [];
disposables.push(
DOM.on('#pr-filter [data-tab]', 'click', e =>
this.onSelectTab(e, val => {
this._prFilter = val;
this.renderPullRequests();
}),
),
);
disposables.push(
DOM.on('#issue-filter [data-tab]', 'click', e =>
this.onSelectTab(e, val => {
this._issueFilter = val;
this.renderIssues();
}),
),
);
return disposables;
}
protected override onMessageReceived(e: MessageEvent) {
const msg = e.data as IpcMessage;
this.log(`onMessageReceived(${msg.id}): name=${msg.method}`);
@ -41,58 +72,116 @@ export class WorkspacesApp extends App {
this.renderContent();
});
break;
case DidChangeSubscriptionNotificationType.method:
onIpc(DidChangeSubscriptionNotificationType, msg, params => {
this.setState({ ...this.state, subscription: params.subscription, isPlus: params.isPlus });
this.renderContent();
});
break;
}
}
renderContent() {
this.renderPullRequests();
this.renderIssues();
this.renderAccountState();
if (this.state.isPlus) {
this.renderPullRequests();
this.renderIssues();
}
}
renderPullRequests() {
const tableEl = document.getElementById('pull-requests');
if (tableEl == null) return;
if (tableEl.childNodes.length > 1) {
tableEl.childNodes.forEach((node, index) => {
if (index > 0) {
tableEl.removeChild(node);
}
});
}
if (this.state.pullRequests != null && this.state.pullRequests?.length > 0) {
const els = this.state.pullRequests.map(({ pullRequest, reasons }) => {
const rowEl = document.createElement('pull-request-row') as PullRequestRow;
rowEl.pullRequest = pullRequest;
rowEl.reasons = reasons;
const rowEls = tableEl.querySelectorAll('pull-request-row');
rowEls.forEach(el => el.remove());
const noneEl = document.getElementById('no-pull-requests')!;
const loadingEl = document.getElementById('loading-pull-requests')!;
if (this.state.pullRequests == null) {
noneEl.setAttribute('hidden', 'true');
loadingEl.removeAttribute('hidden');
} else if (this.state.pullRequests.length === 0) {
noneEl.removeAttribute('hidden');
loadingEl.setAttribute('hidden', 'true');
} else {
noneEl.setAttribute('hidden', 'true');
loadingEl.setAttribute('hidden', 'true');
this.state.pullRequests.forEach(({ pullRequest, reasons }) => {
if (this._prFilter == null || this._prFilter === '' || reasons.includes(this._prFilter)) {
const rowEl = document.createElement('pull-request-row') as PullRequestRow;
rowEl.pullRequest = pullRequest;
rowEl.reasons = reasons;
return rowEl;
tableEl.append(rowEl);
}
});
tableEl?.append(...els);
}
}
renderIssues() {
const tableEl = document.getElementById('issues');
if (tableEl == null) return;
if (tableEl.childNodes.length > 1) {
tableEl.childNodes.forEach((node, index) => {
if (index > 0) {
tableEl.removeChild(node);
const tableEl = document.getElementById('issues')!;
const rowEls = tableEl.querySelectorAll('issue-row');
rowEls.forEach(el => el.remove());
const noneEl = document.getElementById('no-issues')!;
const loadingEl = document.getElementById('loading-issues')!;
if (this.state.issues == null) {
noneEl.setAttribute('hidden', 'true');
loadingEl.removeAttribute('hidden');
} else if (this.state.issues.length === 0) {
noneEl.removeAttribute('hidden');
loadingEl.setAttribute('hidden', 'true');
} else {
noneEl.setAttribute('hidden', 'true');
loadingEl.setAttribute('hidden', 'true');
this.state.issues.forEach(({ issue, reasons }) => {
if (this._issueFilter == null || this._issueFilter === '' || reasons.includes(this._issueFilter)) {
const rowEl = document.createElement('issue-row') as IssueRow;
rowEl.issue = issue;
rowEl.reasons = reasons;
tableEl.append(rowEl);
}
});
}
}
if (this.state.issues != null && this.state.issues?.length > 0) {
const els = this.state.issues.map(({ issue, reasons }) => {
const rowEl = document.createElement('issue-row') as IssueRow;
rowEl.issue = issue;
rowEl.reasons = reasons;
return rowEl;
});
tableEl?.append(...els);
renderAccountState() {
const content = document.getElementById('content')!;
const overlay = document.getElementById('overlay')!;
if (this.state.isPlus) {
content.removeAttribute('aria-hidden');
overlay.setAttribute('hidden', 'true');
} else {
content.setAttribute('aria-hidden', 'true');
overlay.removeAttribute('hidden');
}
// const badgeEl = document.getElementById('account-badge')! as AccountBadge;
const badgeEl = document.createElement('account-badge') as AccountBadge;
badgeEl.subscription = this.state.subscription;
const headerEl = document.getElementById('header')!;
headerEl.innerHTML = '';
headerEl.append(badgeEl);
}
onSelectTab(e: Event, callback?: (val?: string) => void) {
const thisEl = e.target as HTMLElement;
const tab = thisEl.dataset.tab!;
thisEl.parentElement?.querySelectorAll('[data-tab]')?.forEach(el => {
if ((el as HTMLElement).dataset.tab !== tab) {
el.classList.remove('is-active');
} else {
el.classList.add('is-active');
}
});
callback?.(tab);
}
}

+ 145
- 0
src/webviews/apps/shared/components/account/account-badge.ts View File

@ -0,0 +1,145 @@
import { attr, css, customElement, FASTElement, html, observable, ref, volatile, when } from '@microsoft/fast-element';
import type { Subscription } from '../../../../../subscription';
import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../../subscription';
import { fromNow } from '../../../../../system/date';
import { pluralize } from '../../../../../system/string';
import { focusOutline, srOnly } from '../styles/a11y';
import { elementBase } from '../styles/base';
import '../overlays/pop-over';
const template = html<AccountBadge>`
<template>
<span class="badge is-help">
<span class="repo-access ${x => (x.isPro ? 'is-pro' : '')}"></span> ${x => x.label}
</span>
${when(x => x.subText != null, html<AccountBadge>`&nbsp;&nbsp;<small>${x => x.subText}</small>`)}
<pop-over placement="${x => x.placement}" class="badge-popover">
${x => x.popoverText}
<br /><br />
indicates GitLens+ features
</pop-over>
</template>
`;
const styles = css`
${elementBase}
:host {
position: relative;
}
:host(:focus) {
${focusOutline}
}
.badge {
font-size: 1rem;
font-weight: 700;
text-transform: uppercase;
color: var(--color-foreground);
}
.badge.is-help {
cursor: help;
}
.badge small {
font-size: inherit;
opacity: 0.6;
font-weight: 400;
}
.badge-container {
position: relative;
}
.badge-popover {
width: max-content;
right: 0;
top: 100%;
text-align: left;
}
.badge:not(:hover) + .badge-popover {
display: none;
}
`;
@customElement({
name: 'account-badge',
template: template,
styles: styles,
})
export class AccountBadge extends FASTElement {
@attr
placement = 'top end';
@observable
subscription?: Subscription;
@volatile
get isPro() {
if (this.subscription == null) {
return false;
}
return ![
SubscriptionState.Free,
SubscriptionState.FreePreviewTrialExpired,
SubscriptionState.FreePlusTrialExpired,
SubscriptionState.VerificationRequired,
].includes(this.subscription.state);
}
@volatile
get isTrial() {
if (this.subscription == null) {
return false;
}
return [SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(
this.subscription?.state,
);
}
@volatile
get label() {
if (this.subscription == null) {
return 'GitLens Free';
}
let label = this.subscription.plan.effective.name;
switch (this.subscription?.state) {
case SubscriptionState.Free:
case SubscriptionState.FreePreviewTrialExpired:
case SubscriptionState.FreePlusTrialExpired:
label = 'GitLens Free';
break;
case SubscriptionState.FreeInPreviewTrial:
case SubscriptionState.FreePlusInTrial: {
label = 'GitLens Pro (Trial)';
break;
}
case SubscriptionState.VerificationRequired:
label = `${label} (Unverified)`;
break;
}
return label;
}
@volatile
get subText() {
if (this.isTrial) {
const days = getSubscriptionTimeRemaining(this.subscription!, 'days') ?? 0;
return `${days < 1 ? '<1 day' : pluralize('day', days)} left`;
}
return undefined;
}
@volatile
get popoverText() {
return this.isPro
? 'You have access to all GitLens and GitLens+ features on any repo.'
: 'You have access to GitLens+ features on local & public repos, and all other GitLens features on any repo.';
}
}

Loading…
Cancel
Save