Combine all branches in a single sidebar section

- the sidebar resizing overflow problem is now fixed
This commit is contained in:
Mattias Granlund 2023-12-03 20:00:04 +01:00
parent 58269340b4
commit 1d5da80125
10 changed files with 198 additions and 101 deletions

View File

@ -1,22 +1,33 @@
import type { PullRequest } from '$lib/github/types';
import type { RemoteBranch } from '$lib/vbranches/types';
import type { Branch, RemoteBranch } from '$lib/vbranches/types';
import { CombinedBranch } from '$lib/branches/types';
import { Observable, combineLatest } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import type { RemoteBranchService } from '$lib/stores/remoteBranches';
import type { PrService } from '$lib/github/pullrequest';
import type { VirtualBranchService } from '$lib/vbranches/branchStoresCache';
export class BranchService {
public branches$: Observable<CombinedBranch[]>;
constructor(remoteBranchService: RemoteBranchService, prService: PrService) {
const prWithEmpty$ = prService.prs$.pipe(startWith([]));
constructor(
vbranchService: VirtualBranchService,
remoteBranchService: RemoteBranchService,
prService: PrService
) {
const vbranchesWithEmpty$ = vbranchService.branches$.pipe(startWith([]));
const branchesWithEmpty$ = remoteBranchService.branches$.pipe(startWith([]));
this.branches$ = combineLatest([branchesWithEmpty$, prWithEmpty$]).pipe(
const prWithEmpty$ = prService.prs$.pipe(startWith([]));
this.branches$ = combineLatest([vbranchesWithEmpty$, branchesWithEmpty$, prWithEmpty$]).pipe(
switchMap(
([remoteBranches, pullRequests]) =>
([vbranches, remoteBranches, pullRequests]) =>
new Observable<CombinedBranch[]>((observer) => {
const contributions = mergeBranchesAndPrs(pullRequests, remoteBranches || []);
const contributions = mergeBranchesAndPrs(
vbranches,
pullRequests,
remoteBranches || []
);
observer.next(contributions);
})
)
@ -25,34 +36,50 @@ export class BranchService {
}
function mergeBranchesAndPrs(
vbranches: Branch[],
pullRequests: PullRequest[],
remoteBranches: RemoteBranch[]
): CombinedBranch[] {
const contributions: CombinedBranch[] = [];
// branches without pull requests
// First we add everything with a virtual branch
contributions.push(
...vbranches.map((vb) => {
const upstream = vb.upstream?.upstream;
const pr = upstream
? pullRequests.find((pr) => isBranchNameMatch(pr.targetBranch, upstream))
: undefined;
return new CombinedBranch({ vbranch: vb, remoteBranch: vb.upstream, pr });
})
);
// Then remote branches that have no virtual branch, combined with pull requests if present
contributions.push(
...remoteBranches
.filter((b) => !pullRequests.some((pr) => brachesMatch(pr.targetBranch, b.name)))
.map((remoteBranch) => new CombinedBranch({ remoteBranch }))
);
// pull requests without branches
contributions.push(
...pullRequests
.filter((pr) => !remoteBranches.some((branch) => brachesMatch(pr.targetBranch, branch.name)))
.map((pr) => new CombinedBranch({ pr }))
);
// branches with pull requests
contributions.push(
...remoteBranches
.filter((branch) => pullRequests.some((pr) => brachesMatch(pr.targetBranch, branch.name)))
.map((remoteBranch) => {
const pr = pullRequests.find((pr) => brachesMatch(pr.targetBranch, remoteBranch.name));
return new CombinedBranch({ pr, remoteBranch });
.filter((rb) => !vbranches.some((vb) => isBranchNameMatch(rb.name, vb.upstreamName)))
.map((rb) => {
const pr = pullRequests.find((pr) => isBranchNameMatch(pr.targetBranch, rb.name));
return new CombinedBranch({ remoteBranch: rb, pr });
})
);
return contributions.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
// And finally pull requests that lack any corresponding branch
contributions.push(
...pullRequests
.filter((pr) => !remoteBranches.some((rb) => isBranchNameMatch(pr.targetBranch, rb.name)))
.map((pr) => {
return new CombinedBranch({ pr });
})
);
// This should be everything considered a branch in one list
const filtered = contributions
.filter((b) => !b.vbranch || !b.vbranch.active)
.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
return filtered;
}
function brachesMatch(left: string, right: string): boolean {
function isBranchNameMatch(left: string | undefined, right: string | undefined): boolean {
if (!left || !right) return false;
return left.split('/').pop() === right.split('/').pop();
}

View File

@ -1,58 +1,86 @@
import type { PullRequest } from '$lib/github/types';
import type { Author, RemoteBranch } from '$lib/vbranches/types';
import type { Author, Branch, RemoteBranch } from '$lib/vbranches/types';
import type iconsJson from '$lib/icons/icons.json';
import type { IconColor } from '$lib/icons/Icon.svelte';
export class CombinedBranch {
pr?: PullRequest;
branch?: RemoteBranch;
remoteBranch?: RemoteBranch;
vbranch?: Branch;
constructor({ pr, remoteBranch }: { pr?: PullRequest; remoteBranch?: RemoteBranch }) {
constructor({
vbranch,
remoteBranch,
pr
}: {
vbranch?: Branch;
remoteBranch?: RemoteBranch;
pr?: PullRequest;
}) {
this.vbranch = vbranch;
this.remoteBranch = remoteBranch;
this.pr = pr;
this.branch = remoteBranch;
}
get displayName(): string {
if (this.vbranch) return this.vbranch.name;
if (this.pr) return this.pr.title;
if (this.branch) return this.branch.displayName;
if (this.remoteBranch) return this.remoteBranch.displayName;
return 'unknown';
}
get authors(): Author[] {
const authors: Author[] = [];
if (this.pr?.author) {
return [this.pr.author];
} else if (this.branch) {
return this.branch.authors;
authors.push(this.pr.author);
}
throw 'No author found';
if (this.remoteBranch) {
// TODO: Is there a better way to filter out duplicates?
authors.push(
...this.remoteBranch.authors.filter((a) => !authors.some((b) => a.email == b.email))
);
}
if (this.vbranch) {
authors.push({ name: 'you', email: 'none', isBot: false });
}
return authors;
}
get author(): Author {
if (this.pr?.author) return this.pr.author;
else if (this.branch?.authors) return this.branch.authors[0];
throw 'No author found';
if (this.authors.length == 0) {
throw 'No author found';
}
return this.authors[0];
}
get icon(): keyof typeof iconsJson {
if (this.vbranch) return 'branch';
if (this.pr) return 'pr';
else if (this.branch) return 'branch';
throw 'No author found';
if (this.remoteBranch) return 'branch';
throw 'No icon found';
}
get color(): IconColor {
if (this.pr?.mergedAt) return 'pop';
else if (this.branch?.isMergeable) return 'success';
return 'error';
if (this.vbranch && this.vbranch.active == false) return 'warn';
if (this.remoteBranch?.isMergeable) return 'success';
return 'pop';
}
get createdAt(): Date {
if (this.pr) return this.pr.createdAt;
if (this.branch) return this.branch.lastCommitTs;
throw 'unknown error';
}
get modifiedAt(): Date {
if (this.pr) return this.pr.modifiedAt || this.pr.createdAt;
if (this.branch) return this.branch.firstCommitAt || this.branch.lastCommitTs;
throw 'unknown error';
if (this.remoteBranch) return this.remoteBranch.lastCommitTs;
const vbranch = this.vbranch;
if (vbranch) {
const files = vbranch.files;
if (files && files.length > 0) return files[0].modifiedAt;
const localCommits = vbranch.commits;
if (localCommits && localCommits.length > 0) return localCommits[0].createdAt;
const remoteCommits = this.vbranch?.upstream?.commits;
if (remoteCommits && remoteCommits.length > 0) return remoteCommits[0].createdAt;
}
return new Date(0);
}
}

View File

@ -1,5 +1,5 @@
<script lang="ts" context="module">
export type IconColor = 'success' | 'error' | 'pop' | undefined;
export type IconColor = 'success' | 'error' | 'pop' | 'warn' | undefined;
</script>
<script lang="ts">
@ -19,6 +19,7 @@
class:success={color == 'success'}
class:error={color == 'error'}
class:pop={color == 'pop'}
class:warn={color == 'warn'}
class:default={!color}
>
<path fill="currentColor" d={iconsJson[name]}></path>
@ -41,6 +42,9 @@
.pop {
color: var(--clr-core-pop-40);
}
.warn {
color: var(--clr-core-warn-40);
}
.spinning {
transform-origin: center;

View File

@ -101,10 +101,10 @@ export class RemoteFile {
}
export interface Author {
email: string;
email?: string;
name: string;
gravatarUrl?: URL;
isBot: boolean;
isBot?: boolean;
}
export class RemoteBranch {

View File

@ -89,7 +89,6 @@
<div bind:this={trayViewport} class="z-30 flex flex-shrink">
{#if $project$}
<Navigation
{vbranchService}
{branchService}
{baseBranchService}
{branchController}

View File

@ -34,7 +34,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
);
const prService = new PrService(branchController, vbranchService, githubContext$);
const branchService = new BranchService(remoteBranchService, prService);
const branchService = new BranchService(vbranchService, remoteBranchService, prService);
return {
projectId,

View File

@ -1,5 +1,5 @@
<script lang="ts" context="module">
export type TypeFilter = 'all' | 'branch' | 'pr';
export type TypeFilter = 'all' | 'branch' | 'pr' | 'vbranch';
</script>
<script lang="ts">
@ -13,7 +13,8 @@
let options: { id: TypeFilter; name: string }[] = [
{ id: 'all', name: 'All' },
{ id: 'branch', name: 'Branch' },
{ id: 'vbranch', name: 'Virtual' },
{ id: 'branch', name: 'Remote' },
{ id: 'pr', name: 'Pull request' }
];

View File

@ -8,15 +8,19 @@
export let projectId: string;
export let branch: CombinedBranch;
$: href = branch.pr
? `/${projectId}/pull/${branch.pr.number}`
: `/${projectId}/remote/${branch?.branch?.sha}`;
function getBranchLink(b: CombinedBranch): string | undefined {
if (b.pr) return `/${projectId}/pull/${b.pr.number}`;
if (b.vbranch?.active) return `/${projectId}/board/`;
if (b.vbranch) return `/${projectId}/stashed/${b.vbranch.id}`;
if (b.remoteBranch) return `/${projectId}/remote/${branch?.remoteBranch?.sha}`;
}
$: selected = $page.url.href.includes(href);
$: href = getBranchLink(branch);
$: selected = href ? $page.url.href.includes(href) : false;
</script>
<a class="item" class:selected {href}>
<div class="item__icon"><Icon name={branch.icon} color="pop" /></div>
<div class="item__icon"><Icon name={branch.icon} color={branch.color} /></div>
<div class="item__info flex flex-col gap-2 overflow-hidden">
<p class="text-base-13 truncate">
{branch.displayName}
@ -25,8 +29,10 @@
class="text-base-11 flex w-full justify-between"
style="color: var(--clr-theme-scale-ntrl-50)"
>
<TimeAgo date={branch.createdAt} />
by {branch.author?.name ?? 'unknown'}
<TimeAgo date={branch.modifiedAt} />
{#if branch.author}
by {branch.author?.name ?? 'unknown'}
{/if}
<AuthorIcons authors={branch.authors} />
</p>
</div>

View File

@ -4,7 +4,7 @@
import type { UIEventHandler } from 'svelte/elements';
import BranchItem from './BranchItem.svelte';
import Resizer from '$lib/components/Resizer.svelte';
import { getContext } from 'svelte';
import { getContext, onDestroy, onMount } from 'svelte';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/settings/userSettings';
import SectionHeader from './SectionHeader.svelte';
import { accordion } from './accordion';
@ -27,9 +27,14 @@
(branches, type, search) => searchFilter(typeFilter(branches, type), search)
);
let resizeGuard: HTMLElement;
let viewport: HTMLElement;
let rsViewport: HTMLElement;
let contents: HTMLElement;
let observer: ResizeObserver;
let maxHeight: number | undefined = undefined;
let scrolled: boolean;
const onScroll: UIEventHandler<HTMLDivElement> = (e) => {
scrolled = e.currentTarget.scrollTop != 0;
@ -39,8 +44,10 @@
switch (type) {
case 'all':
return branches;
case 'vbranch':
return branches.filter((b) => b.vbranch);
case 'branch':
return branches.filter((b) => b.branch && !b.pr);
return branches.filter((b) => b.remoteBranch);
case 'pr':
return branches.filter((b) => b.pr);
}
@ -50,50 +57,80 @@
if (search == undefined) return branches;
return branches.filter((b) => b.displayName.includes(search));
}
function updateResizable() {
if (resizeGuard) {
maxHeight = resizeGuard.offsetHeight;
}
}
onMount(() => {
updateResizable();
observer = new ResizeObserver(() => updateResizable());
if (viewport) observer.observe(resizeGuard);
});
onDestroy(() => observer.disconnect());
</script>
<div class="relative flex flex-col">
{#if expanded}
<Resizer
{viewport}
direction="up"
inside
minHeight={90}
on:height={(e) => {
userSettings.update((s) => ({
...s,
vbranchExpandableHeight: e.detail
}));
}}
/>
{/if}
<SectionHeader {scrolled} count={$branches$?.length ?? 0} expandable={true} bind:expanded>
Other branches
</SectionHeader>
<div class="resize-guard" bind:this={resizeGuard}>
<div
class="wrapper"
use:accordion={$branches$?.length > 0 && expanded}
class="branch-list"
bind:this={rsViewport}
style:height={`${$userSettings.vbranchExpandableHeight}px`}
style:max-height={maxHeight ? `${maxHeight}px` : undefined}
>
<div bind:this={viewport} class="viewport hide-native-scrollbar" on:scroll={onScroll}>
<BranchFilter {typeFilter$} {textFilter$}></BranchFilter>
<div bind:this={contents} class="content">
{#if $filteredBranches$}
{#each $filteredBranches$ as branch}
<BranchItem {projectId} {branch} />
{/each}
{/if}
{#if expanded}
<Resizer
viewport={rsViewport}
direction="up"
inside
minHeight={90}
on:height={(e) => {
userSettings.update((s) => ({
...s,
vbranchExpandableHeight: maxHeight ? Math.min(maxHeight, e.detail) : e.detail
}));
}}
/>
{/if}
<SectionHeader {scrolled} count={$branches$?.length ?? 0} expandable={true} bind:expanded>
Branches
</SectionHeader>
<div class="scroll-container" use:accordion={$branches$?.length > 0 && expanded}>
<div bind:this={viewport} class="viewport hide-native-scrollbar" on:scroll={onScroll}>
<BranchFilter {typeFilter$} {textFilter$}></BranchFilter>
<div bind:this={contents} class="content">
{#if $filteredBranches$}
{#each $filteredBranches$ as branch}
<BranchItem {projectId} {branch} />
{/each}
{/if}
</div>
</div>
<Scrollbar {viewport} {contents} thickness="0.5rem" />
</div>
<Scrollbar {viewport} {contents} thickness="0.5rem" />
</div>
</div>
<style lang="postcss">
.wrapper {
.resize-guard {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: flex-end;
position: relative;
overflow-y: hidden;
}
.scroll-container {
position: relative;
overflow: hidden;
}
.branch-list {
position: relative;
display: flex;
flex-direction: column;
}
.viewport {
display: flex;
flex-direction: column;

View File

@ -5,14 +5,13 @@
import type { User } from '$lib/backend/cloud';
import BaseBranchCard from './BaseBranchCard.svelte';
import type { Project, ProjectService } from '$lib/backend/projects';
import StashedBranches from './StashedBranches.svelte';
import Footer from './Footer.svelte';
import AppUpdater from './AppUpdater.svelte';
import type { Loadable } from '@square/svelte-store';
import type { Update } from '../../updater';
import DomainButton from './DomainButton.svelte';
import type { PrService } from '$lib/github/pullrequest';
import type { BaseBranchService, VirtualBranchService } from '$lib/vbranches/branchStoresCache';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import ProjectSelector from './ProjectSelector.svelte';
import Branches from './Branches.svelte';
import type { BranchService } from '$lib/branches/service';
@ -22,7 +21,6 @@
import * as toasts from '$lib/utils/toasts';
import Resizer from '$lib/components/Resizer.svelte';
export let vbranchService: VirtualBranchService;
export let branchService: BranchService;
export let baseBranchService: BaseBranchService;
export let branchController: BranchController;
@ -36,7 +34,6 @@
$: base$ = baseBranchService.base$;
let stashExpanded = true;
let branchesExpanded = true;
let viewport: HTMLDivElement;
</script>
@ -92,7 +89,6 @@
</div>
</div>
<Branches projectId={project.id} {branchService} bind:expanded={branchesExpanded} />
<StashedBranches {project} {branchController} {vbranchService} bind:expanded={stashExpanded} />
<Footer {user} projectId={project.id} />
<AppUpdater {update} />
<Resizer
@ -120,7 +116,6 @@
padding-right: var(--space-12);
}
.domains {
flex-grow: 1;
padding-bottom: var(--space-24);
padding-left: var(--space-12);
padding-right: var(--space-12);