mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-29 12:33:49 +03:00
Combine all branches in a single sidebar section
- the sidebar resizing overflow problem is now fixed
This commit is contained in:
parent
58269340b4
commit
1d5da80125
@ -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();
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -89,7 +89,6 @@
|
||||
<div bind:this={trayViewport} class="z-30 flex flex-shrink">
|
||||
{#if $project$}
|
||||
<Navigation
|
||||
{vbranchService}
|
||||
{branchService}
|
||||
{baseBranchService}
|
||||
{branchController}
|
||||
|
@ -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,
|
||||
|
@ -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' }
|
||||
];
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user