mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-24 18:12:48 +03:00
merge conflict resolve
This commit is contained in:
commit
7a3a9148cb
35
app/src/lib/metrics/projectMetrics.ts
Normal file
35
app/src/lib/metrics/projectMetrics.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export type ProjectMetricsReport = {
|
||||
[key: string]: ProjectMetric | undefined;
|
||||
};
|
||||
|
||||
type ProjectMetric = {
|
||||
value: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
};
|
||||
|
||||
export class ProjectMetrics {
|
||||
private metrics: { [key: string]: ProjectMetric | undefined } = {};
|
||||
|
||||
constructor(readonly projectId?: string) {}
|
||||
|
||||
setMetric(key: string, value: number) {
|
||||
const oldvalue = this.metrics[key];
|
||||
|
||||
const maxValue = Math.max(value, oldvalue?.maxValue || value);
|
||||
const minValue = Math.min(value, oldvalue?.minValue || value);
|
||||
this.metrics[key] = {
|
||||
value,
|
||||
maxValue,
|
||||
minValue
|
||||
};
|
||||
}
|
||||
|
||||
getMetrics(): ProjectMetricsReport {
|
||||
return this.metrics;
|
||||
}
|
||||
|
||||
resetMetric(key: string) {
|
||||
delete this.metrics[key];
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tooltip } from '@gitbutler/ui/utils/tooltip';
|
||||
|
||||
export let name:
|
||||
| 'remote-branch'
|
||||
| 'local-branch'
|
||||
| 'virtual-branch'
|
||||
| 'pr'
|
||||
| 'pr-draft'
|
||||
| 'pr-closed'
|
||||
| undefined;
|
||||
export let help: string | undefined;
|
||||
|
||||
function getIconColor(name: string | undefined) {
|
||||
if (name === 'remote-branch') return 'neutral';
|
||||
if (name === 'virtual-branch' || name === 'local-branch') return 'virtual';
|
||||
if (name === 'pr') return 'success';
|
||||
if (name === 'pr-draft') return 'purple';
|
||||
if (name === 'pr-closed') return 'neutral';
|
||||
|
||||
return 'neutral';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="branch-icon {getIconColor(name)}" use:tooltip={help}>
|
||||
{#if name === 'virtual-branch'}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.75 8V4H4.25V8H5.75Z" />
|
||||
<path
|
||||
d="M11.75 7V4H10.25V7C10.25 8.24264 9.24264 9.25 8 9.25C5.92893 9.25 4.25 10.9289 4.25 13V16H5.75V13C5.75 11.7574 6.75736 10.75 8 10.75C10.0711 10.75 11.75 9.07107 11.75 7Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{#if name === 'remote-branch' || name === 'local-branch'}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.75 9.99973V4H4.25V16H5.75V13C5.75 11.7574 6.75736 10.75 8 10.75C10.0711 10.75 11.75 9.07107 11.75 7V4H10.25V7C10.25 8.24264 9.24264 9.25 8 9.25C7.1558 9.25 6.37675 9.52896 5.75 9.99973Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{#if name === 'pr'}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 5L8 2V4.25H10C11.5188 4.25 12.75 5.48122 12.75 7V16H11.25V7C11.25 6.30964 10.6904 5.75 10 5.75H8V8L3 5Z"
|
||||
/>
|
||||
<path d="M4.25 9L4.25 16H5.75L5.75 9H4.25Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if name === 'pr-draft'}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11.75 7.88555C12.7643 7.56698 13.5 6.61941 13.5 5.5C13.5 4.11929 12.3807 3 11 3C9.61929 3 8.5 4.11929 8.5 5.5C8.5 6.61941 9.23572 7.56698 10.25 7.88555V9H11.75V7.88555Z"
|
||||
/>
|
||||
<path d="M4.25 16V4L5.75 4L5.75 16H4.25Z" />
|
||||
<path d="M10.25 13L10.25 11H11.75L11.75 13H10.25Z" />
|
||||
<path d="M10.25 16V15H11.75V16H10.25Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if name === 'pr-closed'}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.99999 4.93934L12.4697 2.46967L13.5303 3.53033L11.0607 6L13.5303 8.46967L12.4697 9.53033L9.99999 7.06066L7.53033 9.53033L6.46967 8.46967L8.93933 6L6.46967 3.53033L7.53033 2.46967L9.99999 4.93934Z"
|
||||
/>
|
||||
<path d="M3.25 4.00001V16H4.75L4.75 4.00001H3.25Z" />
|
||||
<path d="M9.25 10L9.25 16H10.75L10.75 10H9.25Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.branch-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-s);
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
& path {
|
||||
fill: var(--clr-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.virtual {
|
||||
background-color: var(--clr-scale-ntrl-60);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background-color: var(--clr-scale-ntrl-50);
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: var(--clr-scale-succ-50);
|
||||
}
|
||||
|
||||
.purple {
|
||||
background-color: var(--clr-scale-purp-50);
|
||||
}
|
||||
</style>
|
@ -1,15 +1,11 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface SegmentItem {
|
||||
id: string;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
}
|
||||
export interface SegmentContext {
|
||||
focusedSegmentIndex: Writable<number>;
|
||||
selectedSegmentIndex: Writable<number>;
|
||||
length: Writable<number>;
|
||||
setIndex(): number;
|
||||
addSegment(segment: SegmentItem): void;
|
||||
setSelected(index: number): void;
|
||||
setSelected({ index, id }: { index: number; id: string }): void;
|
||||
}
|
||||
|
256
apps/desktop/src/lib/navigation/BranchItemNew.svelte
Normal file
256
apps/desktop/src/lib/navigation/BranchItemNew.svelte
Normal file
@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/shared/Icon.svelte';
|
||||
import TimeAgo from '$lib/shared/TimeAgo.svelte';
|
||||
import { stringToColor } from '@gitbutler/ui/utils/stringToColor';
|
||||
import { tooltip } from '@gitbutler/ui/utils/tooltip';
|
||||
import type { CombinedBranch } from '$lib/branches/types';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
branch: CombinedBranch;
|
||||
}
|
||||
|
||||
const { projectId, branch }: Props = $props();
|
||||
|
||||
let href = $derived(getBranchLink(branch));
|
||||
let selected = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
selected = href ? $page.url.href.endsWith(href) : false;
|
||||
// console.log(branch);
|
||||
});
|
||||
|
||||
function getBranchLink(b: CombinedBranch): string | undefined {
|
||||
if (b.vbranch) return `/${projectId}/board/`;
|
||||
if (b.remoteBranch) return `/${projectId}/remote/${branch?.remoteBranch?.displayName}`;
|
||||
if (b.pr) return `/${projectId}/pull/${b.pr.number}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="branch"
|
||||
class:selected
|
||||
onmousedown={() => {
|
||||
if (href) goto(href);
|
||||
}}
|
||||
>
|
||||
<h4 class="text-base-13 text-semibold branch-name">
|
||||
{branch.displayName}
|
||||
</h4>
|
||||
|
||||
<div class="row">
|
||||
<div class="row-group">
|
||||
<div class="branch-authors">
|
||||
{#each branch.authors as author}
|
||||
<div
|
||||
use:tooltip={{
|
||||
text: author.name || 'Unknown',
|
||||
delay: 500
|
||||
}}
|
||||
class="author-avatar"
|
||||
style:background-color={stringToColor(author.name)}
|
||||
style:background-image="url({author.gravatarUrl})"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="branch-remotes">
|
||||
<!-- NEED API -->
|
||||
{#if branch.remoteBranch}
|
||||
<div class="branch-tag tag-remote">
|
||||
<span class="text-base-10 text-semibold">origin</span>
|
||||
</div>
|
||||
<!-- <div class="branch-tag tag-local">
|
||||
<span class="text-base-10 text-semibold">local</span>
|
||||
</div> -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-group">
|
||||
{#if branch.pr}
|
||||
<div use:tooltip={{ text: branch.pr.title, delay: 500 }} class="branch-tag tag-pr">
|
||||
<span class="text-base-10 text-semibold">PR</span>
|
||||
<Icon name="pr-small" />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- NEED API -->
|
||||
<!-- <div class="branch-tag tag-applied">
|
||||
<span class="text-base-10 text-semibold">applied</span>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="branch-time text-base-11 details truncate">
|
||||
<TimeAgo date={branch.modifiedAt} />
|
||||
{#if branch.author?.name}
|
||||
by {branch.author?.name}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- NEED API -->
|
||||
<div class="stats">
|
||||
<div use:tooltip={'Number of commits'} class="branch-tag tag-commits">
|
||||
<span class="text-base-10 text-semibold">34</span>
|
||||
<Icon name="commit" />
|
||||
</div>
|
||||
|
||||
<div use:tooltip={'Code changes'} class="code-changes">
|
||||
<span class="text-base-10 text-semibold">+289</span>
|
||||
<span class="text-base-10 text-semibold">-129</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style lang="postcss">
|
||||
.branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 14px 12px 14px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--clr-border-3);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ROW */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.row-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* AUTHORS */
|
||||
|
||||
.branch-authors {
|
||||
display: flex;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
margin-left: -4px;
|
||||
background-color: var(--clr-scale-ntrl-50);
|
||||
background-size: cover;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* TAG */
|
||||
|
||||
.branch-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.tag-remote {
|
||||
background-color: var(--clr-theme-ntrl-soft-hover);
|
||||
color: var(--clr-text-1);
|
||||
}
|
||||
|
||||
.tag-local {
|
||||
background-color: var(--clr-theme-ntrl-soft-hover);
|
||||
color: var(--clr-text-2);
|
||||
}
|
||||
|
||||
.tag-pr {
|
||||
background-color: var(--clr-theme-succ-element);
|
||||
color: var(--clr-theme-succ-on-element);
|
||||
}
|
||||
|
||||
.tag-applied {
|
||||
background-color: var(--clr-scale-ntrl-40);
|
||||
color: var(--clr-theme-ntrl-on-element);
|
||||
margin-left: 4px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-commits {
|
||||
background-color: var(--clr-bg-3);
|
||||
color: var(--clr-text-2);
|
||||
}
|
||||
|
||||
/* */
|
||||
|
||||
.code-changes {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
|
||||
& span {
|
||||
padding: 2px 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& span:first-child {
|
||||
background-color: var(--clr-theme-succ-soft);
|
||||
color: var(--clr-theme-succ-on-soft);
|
||||
border-radius: var(--radius-s) 0 0 var(--radius-s);
|
||||
}
|
||||
|
||||
& span:last-child {
|
||||
background-color: var(--clr-theme-err-soft);
|
||||
color: var(--clr-theme-err-on-soft);
|
||||
border-radius: 0 var(--radius-s) var(--radius-s) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.branch-remotes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.branch-name {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-time {
|
||||
color: var(--clr-scale-ntrl-50);
|
||||
}
|
||||
|
||||
.branch:not(.selected):hover,
|
||||
.branch:not(.selected):focus {
|
||||
background-color: var(--clr-bg-1-muted);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--clr-bg-2);
|
||||
}
|
||||
</style>
|
62
apps/desktop/src/lib/navigation/BranchesHeaderNew.svelte
Normal file
62
apps/desktop/src/lib/navigation/BranchesHeaderNew.svelte
Normal file
@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/shared/Badge.svelte';
|
||||
import TextBox from '$lib/shared/TextBox.svelte';
|
||||
import Segment from '@gitbutler/ui/SegmentControl/Segment.svelte';
|
||||
import SegmentControl from '@gitbutler/ui/SegmentControl/SegmentControl.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
filteredBranchCount?: number;
|
||||
totalBranchCount: number;
|
||||
filterButton?: Snippet<[filtersActive: boolean]>;
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
|
||||
const { filteredBranchCount, onSearch }: Props = $props();
|
||||
|
||||
let searchValueState = $state('');
|
||||
</script>
|
||||
|
||||
<div class="header">
|
||||
<div class="branches-title">
|
||||
<span class="text-base-14 text-bold">Branches</span>
|
||||
|
||||
{#if filteredBranchCount !== undefined}
|
||||
<Badge count={filteredBranchCount} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<TextBox
|
||||
icon="search"
|
||||
placeholder="Search"
|
||||
on:input={(e) => onSearch(e.detail)}
|
||||
value={searchValueState}
|
||||
/>
|
||||
|
||||
<SegmentControl fullWidth selectedIndex={0}>
|
||||
<Segment id="all">All</Segment>
|
||||
<Segment id="mine">PRs</Segment>
|
||||
<Segment id="active">Mine</Segment>
|
||||
</SegmentControl>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--clr-scale-ntrl-0);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 14px 14px 12px 14px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-bottom var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
.branches-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
200
apps/desktop/src/lib/navigation/BranchesNew.svelte
Normal file
200
apps/desktop/src/lib/navigation/BranchesNew.svelte
Normal file
@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import BranchItemNew from './BranchItemNew.svelte';
|
||||
import BranchesHeaderNew from './BranchesHeaderNew.svelte';
|
||||
import noBranchesSvg from '$lib/assets/empty-state/no-branches.svg?raw';
|
||||
import { getBranchServiceStore } from '$lib/branches/service';
|
||||
// import FilterButton from '$lib/components/FilterBranchesButton.svelte';
|
||||
// import { getGitHost } from '$lib/gitHost/interface/gitHost';
|
||||
// import { persisted } from '$lib/persisted/persisted';
|
||||
import ScrollableContainer from '$lib/shared/ScrollableContainer.svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { CombinedBranch } from '$lib/branches/types';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const { projectId }: Props = $props();
|
||||
|
||||
const branchService = getBranchServiceStore();
|
||||
|
||||
let searchValue = $state<undefined | string>();
|
||||
let branches = $state($branchService?.branches || readable([]));
|
||||
let searchedBranches = $derived(filterByText($branches, searchValue));
|
||||
let groupedBranches = $derived(groupByDate(searchedBranches));
|
||||
|
||||
let viewport = $state<HTMLDivElement>();
|
||||
let contents = $state<HTMLElement>();
|
||||
|
||||
$effect(() => {
|
||||
branches = $branchService?.branches || readable([]);
|
||||
});
|
||||
|
||||
function filterByText(branches: CombinedBranch[], searchText: string | undefined) {
|
||||
// console.log('filterByText', branches, searchText);
|
||||
if (searchText === undefined || searchText === '') return branches;
|
||||
|
||||
return branches.filter((b) => searchMatchesAnIdentifier(searchText, b.searchableIdentifiers));
|
||||
}
|
||||
|
||||
function searchMatchesAnIdentifier(search: string, identifiers: string[]) {
|
||||
for (const identifier of identifiers) {
|
||||
if (identifier.includes(search.toLowerCase())) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function groupByDate(branches: CombinedBranch[]) {
|
||||
const grouped: Record<string, CombinedBranch[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
lastWeek: [],
|
||||
older: []
|
||||
};
|
||||
|
||||
const currentTs = new Date().getTime();
|
||||
|
||||
const remoteBranches = branches.filter((b) => b.remoteBranch);
|
||||
|
||||
remoteBranches.forEach((b) => {
|
||||
if (!b.modifiedAt) {
|
||||
grouped.older.push(b);
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiedAt = b.modifiedAt?.getTime();
|
||||
const ms = currentTs - modifiedAt;
|
||||
|
||||
if (ms < 86400 * 1000) {
|
||||
grouped.today.push(b);
|
||||
} else if (ms < 2 * 86400 * 1000) {
|
||||
grouped.yesterday.push(b);
|
||||
} else if (ms < 7 * 86400 * 1000) {
|
||||
grouped.lastWeek.push(b);
|
||||
} else {
|
||||
grouped.older.push(b);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet branchGroup(props: {
|
||||
title: string,
|
||||
children: CombinedBranch[]
|
||||
|
||||
})}
|
||||
{#if props.children.length > 0}
|
||||
<div class="group">
|
||||
<h3 class="text-base-12 text-semibold group-header">{props.title}</h3>
|
||||
{#each props.children as branch}
|
||||
<BranchItemNew {projectId} {branch} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="branches">
|
||||
<BranchesHeaderNew
|
||||
totalBranchCount={$branches.length}
|
||||
filteredBranchCount={searchedBranches?.length}
|
||||
onSearch={(value) => (searchValue = value)}
|
||||
>
|
||||
<!-- {#snippet filterButton()}
|
||||
<FilterButton
|
||||
{filtersActive}
|
||||
{includePrs}
|
||||
{includeRemote}
|
||||
{hideBots}
|
||||
{hideInactive}
|
||||
showPrCheckbox={!!$gitHost}
|
||||
on:action
|
||||
/>
|
||||
{/snippet} -->
|
||||
</BranchesHeaderNew>
|
||||
{#if $branches.length > 0}
|
||||
{#if searchedBranches.length > 0}
|
||||
<ScrollableContainer
|
||||
bind:viewport
|
||||
showBorderWhenScrolled
|
||||
fillViewport={searchedBranches.length === 0}
|
||||
>
|
||||
<div bind:this={contents} class="scroll-container">
|
||||
{@render branchGroup({ title: 'Today', children: groupedBranches.today })}
|
||||
{@render branchGroup({ title: 'Yesterday', children: groupedBranches.yesterday })}
|
||||
{@render branchGroup({ title: 'Last week', children: groupedBranches.lastWeek })}
|
||||
{@render branchGroup({ title: 'Older', children: groupedBranches.older })}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
{:else}
|
||||
<div class="branches__empty-state">
|
||||
<div class="branches__empty-state__image">
|
||||
{@html noBranchesSvg}
|
||||
</div>
|
||||
<span class="branches__empty-state__caption text-base-body-14 text-semibold"
|
||||
>No branches match your filter</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="branches__empty-state">
|
||||
<div class="branches__empty-state__image">
|
||||
{@html noBranchesSvg}
|
||||
</div>
|
||||
<span class="branches__empty-state__caption text-base-body-14 text-semibold"
|
||||
>You have no branches</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.branches {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--clr-border-2);
|
||||
}
|
||||
|
||||
/* BRANCHES LIST */
|
||||
|
||||
.scroll-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--clr-border-2);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
padding: 20px 14px 4px;
|
||||
color: var(--clr-text-3);
|
||||
}
|
||||
|
||||
/* EMPTY STATE */
|
||||
.branches__empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.branches__empty-state__image {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.branches__empty-state__caption {
|
||||
color: var(--clr-scale-ntrl-60);
|
||||
text-align: center;
|
||||
max-width: 160px;
|
||||
}
|
||||
</style>
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import BaseBranchCard from './BaseBranchCard.svelte';
|
||||
import Branches from './Branches.svelte';
|
||||
import BranchesNew from './BranchesNew.svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
import ProjectSelector from './ProjectSelector.svelte';
|
||||
import DomainButton from '../components/DomainButton.svelte';
|
||||
@ -25,7 +25,6 @@
|
||||
let viewport: HTMLDivElement;
|
||||
let isResizerHovered = false;
|
||||
let isResizerDragging = false;
|
||||
let isScrollbarDragging = false;
|
||||
|
||||
$: isNavCollapsed = persisted<boolean>(false, 'projectNavCollapsed_' + project.id);
|
||||
|
||||
@ -42,7 +41,7 @@
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
|
||||
<aside class="navigation-wrapper" class:hide-fold-button={isScrollbarDragging}>
|
||||
<aside class="navigation-wrapper">
|
||||
<div
|
||||
class="resizer-wrapper"
|
||||
tabindex="0"
|
||||
@ -121,10 +120,7 @@
|
||||
</div>
|
||||
|
||||
{#if !$isNavCollapsed}
|
||||
<Branches
|
||||
projectId={project.id}
|
||||
on:scrollbarDragging={(e) => (isScrollbarDragging = e.detail)}
|
||||
/>
|
||||
<BranchesNew projectId={project.id} />
|
||||
{/if}
|
||||
<Footer projectId={project.id} isNavCollapsed={$isNavCollapsed} />
|
||||
{/if}
|
||||
|
@ -1,29 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { stringToColor } from '@gitbutler/ui/utils/stringToColor';
|
||||
export let name: string | undefined;
|
||||
|
||||
const colors = [
|
||||
'#E78D8D',
|
||||
'#62CDCD',
|
||||
'#EC90D2',
|
||||
'#7DC8D8',
|
||||
'#F1BC55',
|
||||
'#6B6B4C',
|
||||
'#9785DE',
|
||||
'#99CE63',
|
||||
'#636ECE',
|
||||
'#5FD2B0'
|
||||
];
|
||||
|
||||
function nameToColor(name: string | undefined) {
|
||||
const trimmed = name?.replace(/\s/g, '');
|
||||
if (!trimmed) {
|
||||
return `linear-gradient(45deg, ${colors[0][0]} 15%, ${colors[0][1]} 90%)`;
|
||||
}
|
||||
|
||||
const startHash = trimmed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[startHash % colors.length];
|
||||
}
|
||||
|
||||
function getFirstLetter(name: string | undefined) {
|
||||
return name ? name[0].toUpperCase() : '';
|
||||
}
|
||||
@ -31,7 +9,7 @@
|
||||
$: firstLetter = getFirstLetter(name);
|
||||
</script>
|
||||
|
||||
<div class="project-avatar" style:background-color={nameToColor(name)}>
|
||||
<div class="project-avatar" style:background-color={stringToColor(name)}>
|
||||
<svg class="avatar-letter" viewBox="0 0 24 24">
|
||||
<text x="50%" y="54%" text-anchor="middle" alignment-baseline="middle">
|
||||
{firstLetter.toUpperCase()}
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { stringToColor } from '$lib/utils/stringToColor';
|
||||
import { tooltip } from '$lib/utils/tooltip';
|
||||
|
||||
interface Props {
|
||||
@ -12,25 +13,14 @@
|
||||
const { srcUrl, tooltipText, altText }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="image-wrapper">
|
||||
<div class="image-wrapper" style:background-color={stringToColor(altText)}>
|
||||
<img
|
||||
class="avatar"
|
||||
alt={altText}
|
||||
src={srcUrl}
|
||||
loading="lazy"
|
||||
onload={() => (isLoaded = true)}
|
||||
width="100"
|
||||
height="100"
|
||||
class:hidden={!isLoaded}
|
||||
use:tooltip={tooltipText}
|
||||
/>
|
||||
<img
|
||||
class="avatar"
|
||||
class:hidden={isLoaded}
|
||||
alt={altText}
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAASCAYAAAA6yNxSAAAJcklEQVR4AQCBAH7/ANnXrf/Y163/19is/9XZq//R2an/zdqn/8jbpv/C26X/u9qk/7PYpP+r1aX/odGn/5fMqv+Nxq3/gr+x/3e3tf9srrn/YqW8/1idv/9QlsH/SpDB/0aMwf9FisD/R4q9/0qNuv9PkbX/VJaw/1mbq/9eoKb/YqWi/2Sonv9mqpz/AIEAfv8A2Neu/9jXrv/W2K3/1Nms/9Haqv/M2qj/x9um/8Lbpf+72qX/s9il/6vWpv+i0qj/mM2q/43Grv+Dv7L/eLe2/22vuf9ipr3/WZ6//1CXwf9KkcL/Ro3B/0aLwP9HjL3/S466/1CStv9Wl7H/W52s/2Cip/9kpqL/Z6mf/2irnP8AgQB+/wDV17D/1dew/9TYr//S2a7/z9qs/8vbqv/G26j/wdun/7rbpv+z2ab/q9en/6LTqf+Zzqz/j8iv/4TBs/95urb/brK6/2Oqvf9aosD/UZvC/0uVw/9HksL/R5DB/0qQvv9Ok7v/VJa3/1qbsv9goK3/ZaWo/2qppP9trKD/b66e/wCBAH7/ANHXtP/R2LT/0Niz/87Zsf/L2q//yNut/8Tcq//A3Kr/utyp/7Pbqf+s2Kn/pNWr/5vQrf+Ry7H/hsW0/3u9uP9wtrz/Za6//1unwf9SocP/TJzE/0mYxP9Jl8L/TZfA/1OZvf9ZnLj/YKG0/2elr/9tqqr/cq2m/3awov94sqD/AIEAfv8Ay9i4/8vYuP/K2bf/ydq1/8fbs//F3LH/wt2v/77drf+53az/s9ys/63arP+l167/nNOw/5POs/+IyLb/fsK6/3K7vf9ntMH/Xa7D/1Soxf9Oo8b/S6DG/02exP9Rn8L/WKG//2Cku/9op7b/b6ux/3avrP98s6j/gLal/4K3ov8AgQB+/wDE2L3/xNi9/8TZvP/D2rr/wtu4/8Ddtv++3rT/u96x/7fesP+z3q//rdyv/6bZsf+e1rP/ldK1/4vMuf+Axrz/dcDA/2m6w/9ftMX/Vq/H/1CryP9OqMj/UKfG/1anxP9eqMH/Z6u9/3CuuP94srT/gLav/4a5q/+Ku6f/jb2l/wCBAH7/ALzZw/+82cL/vNrB/7zbv/+83L3/u927/7rfuP+437b/teC0/7Hfs/+s3rP/pty0/5/Ytf+W1bj/jNC7/4LLv/92xcL/a8DF/2G7yP9Ytsn/U7LK/1Gwyv9Ur8n/W6/H/2Sww/9usr//eLW7/4G4tv+Ju7H/j76t/5TAqv+Xwaj/AIEAfv8As9rI/7PayP+028b/tNzE/7Xdwv+13r//td+9/7Tguv+y4Lj/r+C3/6vftv+l3bf/n9q4/5bXu/+N077/gs7B/3fJxP9sxcf/YsDK/1q8zP9Vucz/VbfM/1m2y/9gtsn/arfG/3S4wv9/u73/iL24/5HAtP+YwrD/ncSs/6DFqv8AgQB+/wCq283/q9vN/6vcy/+s3Mn/rt7H/6/fxP+v4MH/r+C+/67gvP+s4Lr/qN+5/6Teuv+d27v/ldi9/4zVwP+C0cP/eM3H/23Iyv9jxMz/XMHO/1i+z/9YvM7/XbvN/2W7y/9vvMj/er3E/4W/v/+Pwbr/mMS1/5/Fsf+kx67/p8is/wCBAH7/AKHc0v+i3NH/o93Q/6Tdzv+m3sv/qN/I/6ngxf+q4ML/qeC//6jgvf+l37z/oN28/5rbvv+T2MD/itXC/4HSxf93zsn/bcvM/2THzv9dxND/WsLR/1zA0P9hv8//ar/N/3TAyf+AwcX/i8LB/5XEvP+dxrf/pcey/6rIr/+tya3/AIEAfv8Amd3W/5re1f+b3tT/nd7S/5/fz/+h38v/ot/I/6Pfxf+j38L/ot7A/6Ddv/+c3L//ltrA/4/Xwv+H1cT/ftLH/3XPyv9rzM3/ZMnQ/17G0v9cxNL/X8PS/2XC0f9uwc7/ecLL/4TCxv+PxMH/mcW8/6LGt/+px7P/rsiv/7HJrf8AgQB+/wCS39n/kt/Z/5Pf1/+V39X/l9/S/5rfzv+b38v/nN7H/53exP+c3cL/mdvB/5Xawf+Q2MH/itXD/4LTxv960Mn/cs7M/2nLz/9jydH/X8bT/1/F1P9iw9P/acPS/3LCz/99wsz/iMPH/5LDwv+cxLz/pMW3/6vGsv+wx67/s8es/wCBAH7/AIzg3P+M4Nv/jeDa/4/g1/+R39T/k9/R/5Tezf+V3cn/ldzG/5TaxP+S2ML/jtbC/4nUw/+D0sX/fNDH/3XOyv9uy83/Z8nQ/2LH0/9fxtT/YcTV/2XD1P9swtP/dsLQ/4DCzP+Lwsf/lcLC/57CvP+mw7f/rcOx/7HErf+0xKv/AIEAfv8Ah+Le/4fi3f+I4dz/ieHZ/4vg1v+M3tP/jd3P/43by/+N2cj/jNfF/4nVw/+G08P/gdHE/3zOxv92zMj/b8rL/2nIzv9kx9H/YMXU/2DE1f9iwtb/aMLV/2/B0/95wND/g8DM/43Ax/+WwML/n8C7/6fAtf+twLD/scCs/7TAqf8AgQB+/wCD4+D/g+Pf/4Ti3f+F4dv/heDY/4be1P+G3ND/htnM/4XXyP+E1Mb/gdLE/33PxP95zcT/dMrG/27Iyf9pxsz/ZMXP/2DD0v9fwtT/YMHW/2TA1/9qv9b/cr/U/3u+0f+Fvsz/jr3H/5e9wf+gvLv/p7y0/6y8r/+xvKr/s7yn/wCBAH7/AIHk4f+B5OD/gePf/4Hi3P+B4Nn/gd3V/4Hb0f+A2M3/ftXJ/3zSxv95zsX/dczE/3HJxf9sx8f/Z8XJ/2PDzP9fwdD/XcDT/12/1f9gvtf/Zb7X/2u91v90vdT/fbzR/4a7zP+Pu8f/mLrA/6C5uv+muLP/rLit/7C3qP+yt6X/AIEAfv8Af+Xi/3/l4f9/5N//f+Ld/37g2f993dX/fNrR/3rWzf9408n/dc/H/3LMxf9uycT/asbF/2XDx/9hwcr/XcDN/1u+0P9avdP/XL3V/2C81/9lvNf/bbvW/3W71P9+utH/h7nM/5C4xv+Yt8D/n7a5/6a1sv+rtaz/rrSn/7G0o/8BgQB+/wB+5uL/fuXh/37k4P994t3/fODa/3vd1v952dH/d9bN/3TSyv9xzsf/bcrF/2nHxf9lxMX/YMHH/12/yv9avs3/WLzQ/1m80/9bu9b/YLvX/2a61/9uutb/drnU/3+40f+IuMz/kLfG/5i1v/+ftLj/pbOx/6qyq/+usqb/sLGi/+et3p4B1/E6AAAAAElFTkSuQmCC"
|
||||
width="100"
|
||||
height="100"
|
||||
class:show={isLoaded}
|
||||
use:tooltip={tooltipText}
|
||||
/>
|
||||
</div>
|
||||
@ -40,20 +30,24 @@
|
||||
display: grid;
|
||||
place-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.image-wrapper > * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
.show {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
110
packages/ui/src/lib/SegmentControl/Segment.svelte
Normal file
110
packages/ui/src/lib/SegmentControl/Segment.svelte
Normal file
@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import type { SegmentContext } from './segmentTypes';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface SegmentProps {
|
||||
id: string;
|
||||
onselect?: (id: string) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { id, onselect, children }: SegmentProps = $props();
|
||||
|
||||
const context = getContext<SegmentContext>('SegmentControl');
|
||||
const index = context.setIndex();
|
||||
const selectedSegmentIndex = context.selectedSegmentIndex;
|
||||
|
||||
let elRef = $state<HTMLButtonElement>();
|
||||
let isFocused = $state(false);
|
||||
let isSelected = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
elRef && isFocused && elRef.focus();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
isSelected = $selectedSegmentIndex === index;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
context.addSegment({ index });
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={elRef}
|
||||
{id}
|
||||
class="segment"
|
||||
role="tab"
|
||||
tabindex={isSelected ? -1 : 0}
|
||||
aria-selected={isSelected}
|
||||
onmousedown={() => {
|
||||
if (index !== $selectedSegmentIndex) {
|
||||
context.setSelected({
|
||||
index,
|
||||
id
|
||||
});
|
||||
onselect && onselect(id);
|
||||
}
|
||||
}}
|
||||
onkeydown={({ key }) => {
|
||||
if (key === 'Enter' || key === ' ') {
|
||||
if (index !== $selectedSegmentIndex) {
|
||||
context.setSelected({
|
||||
index,
|
||||
id
|
||||
});
|
||||
onselect && onselect(id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
><span class="text-base-12 label">
|
||||
{@render children()}
|
||||
</span></button
|
||||
>
|
||||
|
||||
<style lang="postcss">
|
||||
.segment {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
border-left-width: 1px;
|
||||
|
||||
color: var(--clr-text-1);
|
||||
border-color: var(--clr-border-2);
|
||||
background-color: var(--clr-bg-1);
|
||||
height: var(--size-button);
|
||||
|
||||
transition: background var(--transition-fast);
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: var(--radius-m);
|
||||
border-bottom-left-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: var(--radius-m);
|
||||
border-bottom-right-radius: var(--radius-m);
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
&:not([aria-selected='true']):hover {
|
||||
background-color: var(--clr-bg-1-muted);
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--clr-bg-2);
|
||||
color: var(--clr-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
53
packages/ui/src/lib/SegmentControl/SegmentControl.svelte
Normal file
53
packages/ui/src/lib/SegmentControl/SegmentControl.svelte
Normal file
@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { SegmentContext, SegmentItem } from './segmentTypes';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface SegmentProps {
|
||||
selectedIndex: number;
|
||||
fullWidth?: boolean;
|
||||
onselect?: (id: string) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { selectedIndex, fullWidth, onselect, children }: SegmentProps = $props();
|
||||
|
||||
let indexesIterator = -1;
|
||||
let segments: SegmentItem[] = [];
|
||||
|
||||
let selectedSegmentIndex = writable(selectedIndex);
|
||||
|
||||
const context: SegmentContext = {
|
||||
selectedSegmentIndex,
|
||||
setIndex: () => {
|
||||
indexesIterator += 1;
|
||||
return indexesIterator;
|
||||
},
|
||||
addSegment: ({ index }) => {
|
||||
segments = [...segments, { index }];
|
||||
},
|
||||
setSelected: ({ index: segmentIndex, id }) => {
|
||||
if (segmentIndex >= 0 && segmentIndex < segments.length) {
|
||||
$selectedSegmentIndex = segmentIndex;
|
||||
onselect && onselect(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setContext<SegmentContext>('SegmentControl', context);
|
||||
</script>
|
||||
|
||||
<div class="wrapper" class:full-width={fullWidth}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrapper.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
11
packages/ui/src/lib/SegmentControl/segmentTypes.ts
Normal file
11
packages/ui/src/lib/SegmentControl/segmentTypes.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface SegmentItem {
|
||||
index: number;
|
||||
}
|
||||
export interface SegmentContext {
|
||||
selectedSegmentIndex: Writable<number>;
|
||||
setIndex(): number;
|
||||
addSegment(segment: SegmentItem): void;
|
||||
setSelected({ index, id }: { index: number; id: string }): void;
|
||||
}
|
22
packages/ui/src/lib/utils/stringToColor.ts
Normal file
22
packages/ui/src/lib/utils/stringToColor.ts
Normal file
@ -0,0 +1,22 @@
|
||||
const colors = [
|
||||
'#E78D8D',
|
||||
'#62CDCD',
|
||||
'#EC90D2',
|
||||
'#7DC8D8',
|
||||
'#F1BC55',
|
||||
'#50D6AE',
|
||||
'#9785DE',
|
||||
'#99CE63',
|
||||
'#636ECE',
|
||||
'#5FD2B0'
|
||||
];
|
||||
|
||||
export function stringToColor(name: string | undefined) {
|
||||
const trimmed = name?.replace(/\s/g, '');
|
||||
if (!trimmed) {
|
||||
return `linear-gradient(45deg, ${colors[0][0]} 15%, ${colors[0][1]} 90%)`;
|
||||
}
|
||||
|
||||
const startHash = trimmed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[startHash % colors.length];
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import SegmentControl from './SegmentControl.svelte';
|
||||
import type { Meta, StoryObj } from '@storybook/svelte';
|
||||
|
||||
const meta = {
|
||||
component: SegmentControl
|
||||
} satisfies Meta<SegmentControl>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SegmentControlStory: Story = {
|
||||
args: {
|
||||
selectedIndex: 1,
|
||||
fullWidth: false
|
||||
}
|
||||
};
|
24
packages/ui/src/stories/SegmentControl/SegmentControl.svelte
Normal file
24
packages/ui/src/stories/SegmentControl/SegmentControl.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Segment from '$lib/SegmentControl/Segment.svelte';
|
||||
import SegmentControl from '$lib/SegmentControl/SegmentControl.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedIndex: number;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const { selectedIndex, fullWidth }: Props = $props();
|
||||
</script>
|
||||
|
||||
<SegmentControl
|
||||
{selectedIndex}
|
||||
{fullWidth}
|
||||
onselect={(id) => {
|
||||
console.log('Selected index:', id);
|
||||
}}
|
||||
>
|
||||
<Segment id="first">First</Segment>
|
||||
<Segment id="second">Second</Segment>
|
||||
<Segment id="third">Third</Segment>
|
||||
<Segment id="fourth">Fourth</Segment>
|
||||
</SegmentControl>
|
Loading…
Reference in New Issue
Block a user