integrated the thign

This commit is contained in:
Caleb Owens 2024-07-31 19:41:08 +02:00
parent 09de80f6f0
commit 70422439d7
49 changed files with 678 additions and 382 deletions

View File

@ -6,7 +6,7 @@
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import Toggle from '$lib/shared/Toggle.svelte';
import { User } from '$lib/stores/user';

View File

@ -94,7 +94,7 @@ export class BranchListing {
* This includes any commits, uncommited changes or even updates to the branch metadata (e.g. renaming).
*/
@Transform((obj) => new Date(obj.value))
updatedAt!: number;
updatedAt!: Date;
/** The person who commited the head commit */
@Type(() => Author)
lastCommiter!: Author;

View File

@ -7,10 +7,10 @@
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext, getContextStore } from '$lib/utils/context';
import { getTimeAgo } from '$lib/utils/timeAgo';
import { getTimeAgo } from '@gitbutler/ui/timeAgo/timeAgo';
import { openExternalUrl } from '$lib/utils/url';
import { BranchController } from '$lib/vbranches/branchController';
import { createCommitStore } from '$lib/vbranches/contexts';

View File

@ -4,7 +4,7 @@
import { projectMergeUpstreamWarningDismissed } from '$lib/config/config';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { showInfo } from '$lib/notifications/toasts';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import { getContext } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import Button from '@gitbutler/ui/inputs/Button.svelte';

View File

@ -1,9 +1,9 @@
<script lang="ts">
import Modal from '../shared/Modal.svelte';
import TextBox from '../shared/TextBox.svelte';
import { PromptService } from '$lib/backend/prompt';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/inputs/Button.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
const promptService = getContext(PromptService);
const [prompt, error] = promptService.reactToPrompt({ timeoutMs: 30000 });

View File

@ -4,7 +4,7 @@
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import { RemotesService } from '$lib/remotes/service';
import Link from '$lib/shared/Link.svelte';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import TextBox from '$lib/shared/TextBox.svelte';
import { getContext } from '$lib/utils/context';
import { getMarkdownRenderer } from '$lib/utils/markdown';

View File

@ -1,5 +1,5 @@
<script lang="ts">
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import Button from '@gitbutler/ui/inputs/Button.svelte';
export let projectTitle: string = '#';

View File

@ -1,138 +0,0 @@
<script lang="ts">
import Icon from '@gitbutler/ui/icon/Icon.svelte';
import { createEventDispatcher, getContext, onMount } from 'svelte';
import type { SegmentContext } from './segment';
import type iconsJson from '@gitbutler/ui/icon/icons.json';
export let id: string;
export let disabled = false;
export let icon: keyof typeof iconsJson | undefined = undefined;
export let label: string | undefined = undefined;
export let size: 'small' | 'medium' = 'medium';
let ref: HTMLButtonElement | undefined;
const dispatcher = createEventDispatcher<{ select: string }>();
const context = getContext<SegmentContext>('SegmentedControl');
const index = context.setIndex();
const focusedSegmentIndex = context.focusedSegmentIndex;
const selectedSegmentIndex = context.selectedSegmentIndex;
const length = context.length;
$: isFocused = $focusedSegmentIndex === index;
$: if (isFocused) {
ref?.focus();
}
$: isSelected = $selectedSegmentIndex === index;
onMount(() => {
context.addSegment({ id, index, disabled });
});
</script>
<button
bind:this={ref}
class="segment-btn segment-{size}"
class:left={index === 0}
class:right={index === $length - 1}
role="tab"
tabindex={isSelected ? -1 : 0}
aria-selected={isSelected}
aria-disabled={disabled}
{...$$restProps}
on:mousedown|preventDefault={() => {
if (index !== $selectedSegmentIndex && !disabled) {
context.setSelected(index);
dispatcher('select', id);
}
}}
on:keydown={({ key }) => {
if (key === 'Enter' || key === ' ') {
if (index !== $selectedSegmentIndex && !disabled) {
context.setSelected(index);
dispatcher('select', id);
}
}
}}
>
{#if label}
<span class="label text-base-12">
{label}
</span>
{/if}
{#if icon}
<div class="icon">
<Icon name={icon} />
</div>
{/if}
</button>
<style lang="postcss">
.segment-btn {
cursor: pointer;
display: inline-flex;
flex-grow: 1;
flex-basis: 0;
align-items: center;
justify-content: center;
gap: 4px;
height: var(--size-button);
border-top-width: 1px;
border-bottom-width: 1px;
border-left-width: 1px;
border-color: var(--clr-border-2);
transition: background var(--transition-fast);
&[aria-selected='true'] {
background-color: var(--clr-bg-2);
cursor: default;
& > .label,
& > .icon {
color: var(--clr-scale-ntrl-50);
cursor: default;
}
&:focus {
outline: none;
}
}
&.left {
border-left-width: 1px;
border-top-left-radius: var(--radius-m);
border-bottom-left-radius: var(--radius-m);
}
&.right {
border-right-width: 1px;
border-top-right-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
}
}
.icon {
display: flex;
justify-content: center;
align-items: center;
color: var(--clr-scale-ntrl-30);
}
.label {
color: var(--clr-scale-ntrl-30);
}
/* MODIFIERS */
.segment-small {
height: var(--size-tag);
padding: 2px 4px;
}
.segment-medium {
height: var(--size-button);
padding: 4px 8px;
}
</style>

View File

@ -1,58 +0,0 @@
<script lang="ts">
import { createEventDispatcher, setContext } from 'svelte';
import { writable } from 'svelte/store';
import type { SegmentContext, SegmentItem } from './segment';
export let wide = false;
export let selectedIndex = 0;
export let selected: string | undefined = undefined;
let dispatch = createEventDispatcher<{ select: string }>();
let indexesIterator = -1;
let segments: SegmentItem[] = [];
let focusedSegmentIndex = writable(-1);
let selectedSegmentIndex = writable(selectedIndex);
let length = writable(0);
const context: SegmentContext = {
focusedSegmentIndex,
selectedSegmentIndex,
length,
setIndex: () => {
indexesIterator += 1;
return indexesIterator;
},
addSegment: ({ id, index, disabled }) => {
segments = [...segments, { id, index, disabled }];
length.set(segments.length);
if (index === selectedIndex) selected = id;
},
setSelected: (segmentIndex) => {
if (segmentIndex >= 0 && segmentIndex < segments.length) {
$focusedSegmentIndex = segmentIndex;
if (!segments[segmentIndex].disabled) {
$selectedSegmentIndex = $focusedSegmentIndex;
selected = segments[segmentIndex].id;
dispatch('select', selected);
}
}
}
};
setContext<SegmentContext>('SegmentedControl', context);
</script>
<div class="wrapper" class:wide>
<slot />
</div>
<style lang="postcss">
.wrapper {
display: inline-flex;
&.wide {
display: flex;
}
}
</style>

View File

@ -1,11 +0,0 @@
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;
}

View File

@ -4,7 +4,7 @@
import { HttpClient } from '$lib/backend/httpClient';
import { invoke, listen } from '$lib/backend/ipc';
import * as zip from '$lib/backend/zip';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import { User } from '$lib/stores/user';
import { getContext, getContextStore } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { BaseBranchService } from '$lib/baseBranch/baseBranchService';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import TimeAgo from '$lib/shared/TimeAgo.svelte';
import Button from '@gitbutler/ui/inputs/Button.svelte';
import TimeAgo from '@gitbutler/ui/timeAgo/TimeAgo.svelte';
import { getContext } from '$lib/utils/context';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import Button from '@gitbutler/ui/inputs/Button.svelte';
const baseBranchService = getContext(BaseBranchService);
const vbranchService = getContext(VirtualBranchService);

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { clickOutside } from '$lib/clickOutside';
import { createKeybind } from '$lib/utils/hotkeys';
import { portal } from '$lib/utils/portal';
import { portal } from '@gitbutler/ui/utils/portal';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { type Snippet } from 'svelte';

View File

@ -4,7 +4,7 @@
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { editor } from '$lib/editorLink/editorLink';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import { getContext } from '$lib/utils/context';
import { computeFileStatus } from '$lib/utils/fileStatus';
import * as toasts from '$lib/utils/toasts';

View File

@ -1,3 +0,0 @@
export function gravatarUrl(id: string | undefined | null): string | undefined {
if (id) return `https://www.gravatar.com/avatar/${id}?s=100&r=g&d=retro`;
}

View File

@ -1,6 +1,6 @@
<script lang="ts">
import BranchIcon from '../branch/BranchIcon.svelte';
import TimeAgo from '$lib/shared/TimeAgo.svelte';
import TimeAgo from '@gitbutler/ui/timeAgo/TimeAgo.svelte';
import type { CombinedBranch } from '$lib/branches/types';
import { goto } from '$app/navigation';
import { page } from '$app/stores';

View File

@ -1,41 +1,18 @@
<script lang="ts">
import BranchItemNew from './BranchItemNew.svelte';
// 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 { BranchListing, BranchListingService } from '$lib/branches/branchListing';
import SmartSidebarEntry from '$lib/navigation/SmartSidebarEntry.svelte';
import ScrollableContainer from '$lib/shared/ScrollableContainer.svelte';
import { readable } from 'svelte/store';
import type { CombinedBranch } from '$lib/branches/types';
import { getContext } from '$lib/utils/context';
interface Props {
projectId: string;
}
const branchListingService = getContext(BranchListingService);
// const gitHostListingService = getGitHostListingService();
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));
}
const branchesStore = branchListingService.branchListings;
const branches = $derived($branchesStore || []);
const searchedBranches = $derived(branches);
function searchMatchesAnIdentifier(search: string, identifiers: string[]) {
for (const identifier of identifiers) {
@ -45,32 +22,30 @@
return false;
}
function groupByDate(branches: CombinedBranch[]) {
const grouped: Record<string, CombinedBranch[]> = {
const oneDay = 1000 * 60 * 60 * 24;
function groupByDate(branches: BranchListing[]) {
const grouped: Record<'today' | 'yesterday' | 'lastWeek' | 'older', BranchListing[]> = {
today: [],
yesterday: [],
lastWeek: [],
older: []
};
const currentTs = new Date().getTime();
const now = Date.now();
const remoteBranches = branches.filter((b) => b.remoteBranch);
remoteBranches.forEach((b) => {
if (!b.modifiedAt) {
branches.forEach((b) => {
if (!b.updatedAt) {
grouped.older.push(b);
return;
}
const modifiedAt = b.modifiedAt?.getTime();
const ms = currentTs - modifiedAt;
const msSinceLastCommit = now - b.updatedAt.getTime();
if (ms < 86400 * 1000) {
if (msSinceLastCommit < oneDay) {
grouped.today.push(b);
} else if (ms < 2 * 86400 * 1000) {
} else if (msSinceLastCommit < 2 * oneDay) {
grouped.yesterday.push(b);
} else if (ms < 7 * 86400 * 1000) {
} else if (msSinceLastCommit < 7 * oneDay) {
grouped.lastWeek.push(b);
} else {
grouped.older.push(b);
@ -79,18 +54,22 @@
return grouped;
}
const groupedBranches = $derived(groupByDate(searchedBranches));
let viewport = $state<HTMLDivElement>();
let contents = $state<HTMLDivElement>();
</script>
{#snippet branchGroup(props: {
title: string,
children: CombinedBranch[]
children: BranchListing[]
})}
{#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 props.children as branchListing}
<SmartSidebarEntry {branchListing} />
{/each}
</div>
{/if}
@ -98,7 +77,7 @@
<div class="branches">
<BranchesHeaderNew
totalBranchCount={$branches.length}
totalBranchCount={branches.length}
filteredBranchCount={searchedBranches?.length}
onSearch={(value) => (searchValue = value)}
>
@ -114,10 +93,11 @@
/>
{/snippet} -->
</BranchesHeaderNew>
{#if $branches.length > 0}
{#if branches.length > 0}
{#if searchedBranches.length > 0}
<ScrollableContainer
bind:viewport
bind:contents
showBorderWhenScrolled
fillViewport={searchedBranches.length === 0}
>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import ProjectAvatar from './ProjectAvatar.svelte';
import ProjectsPopup from './ProjectsPopup.svelte';
import { Project } from '$lib/backend/projects';
import ProjectAvatar from '$lib/navigation/ProjectAvatar.svelte';
import { getContext } from '$lib/utils/context';
import Icon from '@gitbutler/ui/icon/Icon.svelte';
import { tooltip } from '@gitbutler/ui/utils/tooltip';

View File

@ -2,9 +2,9 @@
import { ProjectService } from '$lib/backend/projects';
import ScrollableContainer from '$lib/shared/ScrollableContainer.svelte';
import { getContext } from '$lib/utils/context';
import { portal } from '$lib/utils/portal';
import { resizeObserver } from '$lib/utils/resizeObserver';
import Icon from '@gitbutler/ui/icon/Icon.svelte';
import { portal } from '@gitbutler/ui/utils/portal';
import type iconsJson from '@gitbutler/ui/icon/icons.json';
import { goto } from '$app/navigation';
import { page } from '$app/stores';

View File

@ -0,0 +1,69 @@
<script lang="ts">
import {
BranchListingDetails,
BranchListingService,
type BranchListing
} from '$lib/branches/branchListing';
import { getGitHostListingService } from '$lib/gitHost/interface/gitHostListingService';
import { getContext } from '$lib/utils/context';
import Avatar from '@gitbutler/ui/avatar/Avatar.svelte';
import AvatarGrouping from '@gitbutler/ui/avatar/AvatarGrouping.svelte';
import { gravatarUrlFromEmail } from '@gitbutler/ui/avatar/gravatar';
import SidebarEntry from '@gitbutler/ui/sidebarEntry/SidebarEntry.svelte';
interface Props {
branchListing: BranchListing;
}
const { branchListing }: Props = $props();
const gitHostListingService = getGitHostListingService();
const branchListingService = getContext(BranchListingService);
const prs = $derived($gitHostListingService?.prs);
const pr = $derived($prs?.find((pr) => pr.sourceBranch === branchListing.name));
let branchListingDetails = $state<BranchListingDetails>();
async function onFirstSeen() {
if (!branchListingDetails) {
console.log('doing the math');
branchListingDetails = await branchListingService.getBranchListingDetails(branchListing.name);
}
}
</script>
<SidebarEntry
title={branchListing.name}
remotes={branchListing.remotes}
local={false}
applied={!!branchListing.virtualBranch}
lastCommitDetails={{
authorName: branchListing.lastCommiter.name || 'Unknown',
lastCommitAt: branchListing.updatedAt
}}
pullRequestDetails={pr && {
title: pr.title
}}
branchDetails={branchListingDetails && {
commitCount: branchListing.numberOfCommits,
linesAdded: branchListingDetails.linesAdded,
linesRemoved: branchListingDetails.linesRemoved
}}
{onFirstSeen}
>
{#snippet authorAvatars()}
<AvatarGrouping>
{#each branchListing.authors as author}
{#await gravatarUrlFromEmail(author.email || 'example@example.com') then gravatarUrl}
<Avatar
srcUrl={gravatarUrl}
size="medium"
tooltipText={author.name || 'unknown'}
altText={author.name || 'unknown'}
/>
{/await}
{/each}
</AvatarGrouping>
{/snippet}
</SidebarEntry>

View File

@ -9,7 +9,7 @@
import { getGitHostPrMonitor } from '$lib/gitHost/interface/gitHostPrMonitor';
import { getGitHostPrService } from '$lib/gitHost/interface/gitHostPrService';
import { getContext } from '$lib/utils/context';
import { createTimeAgoStore } from '$lib/utils/timeAgo';
import { createTimeAgoStore } from '@gitbutler/ui/timeAgo/timeAgo';
import * as toasts from '$lib/utils/toasts';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import Button from '@gitbutler/ui/inputs/Button.svelte';

View File

@ -12,7 +12,7 @@
import ScrollableContainer from '../shared/ScrollableContainer.svelte';
import TextBox from '../shared/TextBox.svelte';
import { KeyName } from '$lib/utils/hotkeys';
import { portal } from '$lib/utils/portal';
import { portal } from '@gitbutler/ui/utils/portal';
import { resizeObserver } from '$lib/utils/resizeObserver';
import { type Snippet } from 'svelte';

View File

@ -2,7 +2,7 @@
import { checkAuthStatus, initDeviceOauth } from '$lib/backend/github';
import SectionCard from '$lib/components/SectionCard.svelte';
import { getGitHubUserServiceStore } from '$lib/gitHost/github/githubUserService';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import { UserService } from '$lib/stores/user';
import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext } from '$lib/utils/context';

View File

@ -7,7 +7,7 @@
import Icon, { type IconColor } from '@gitbutler/ui/icon/Icon.svelte';
import Button from '@gitbutler/ui/inputs/Button.svelte';
import { createEventDispatcher } from 'svelte';
import type iconsJson from '../icons/icons.json';
import type iconsJson from '@gitbutler/ui/icon/icons.json';
export let icon: keyof typeof iconsJson | undefined = undefined;
export let style: MessageStyle = 'neutral';

View File

@ -1,11 +0,0 @@
<script lang="ts">
import { createTimeAgoStore } from '$lib/utils/timeAgo';
export let date: Date | undefined;
export let addSuffix = true;
$: store = createTimeAgoStore(date, addSuffix);
</script>
{#if store}
{$store}
{/if}

View File

@ -71,7 +71,7 @@
setContext(CommitDragActionsFactory, data.commitDragActionsFactory);
setContext(ReorderDropzoneManagerFactory, data.reorderDropzoneManagerFactory);
setContext(RemoteBranchService, data.remoteBranchService);
setContext(branchListingService, branchListingService);
setContext(BranchListingService, branchListingService);
});
let intervalId: any;

View File

@ -11,7 +11,7 @@
type Settings,
type ScrollbarVisilitySettings
} from '$lib/settings/userSettings';
import Modal from '$lib/shared/Modal.svelte';
import Modal from '@gitbutler/ui/modal/Modal.svelte';
import RadioButton from '$lib/shared/RadioButton.svelte';
import Spacer from '$lib/shared/Spacer.svelte';
import TextBox from '$lib/shared/TextBox.svelte';

View File

@ -45,6 +45,7 @@
"@sveltejs/vite-plugin-svelte": "catalog:svelte",
"autoprefixer": "^10.4.19",
"cpy-cli": "^5.0.0",
"date-fns": "^2.30.0",
"postcss": "^8.4.38",
"postcss-cli": "^11.0.0",
"postcss-minify": "^1.1.0",

View File

@ -1,5 +1,5 @@
<script lang="ts">
import AvatarImage from './AvatarImage.svelte';
import Avatar from '$lib/avatar/Avatar.svelte';
import { tooltip } from '$lib/utils/tooltip';
import { isDefined } from '$lib/utils/typeguards';
import type { CommitNodeData, Color } from '$lib/CommitLines/types';
@ -34,7 +34,7 @@
>
{#if commitNode.type === 'large' && commitNode.commit}
<div class="large-node">
<AvatarImage
<Avatar
srcUrl={commitNode.commit?.author.gravatarUrl ?? ''}
tooltipText={hoverText}
altText={`Gravatar for ${commitNode.commit.author.email}`}

View File

@ -6,14 +6,15 @@
srcUrl: string;
tooltipText: string;
altText: string;
size?: 'small' | 'medium';
}
let isLoaded = $state(false);
const { srcUrl, tooltipText, altText }: Props = $props();
const { srcUrl, tooltipText, altText, size = 'small' }: Props = $props();
</script>
<div class="image-wrapper" style:background-color={stringToColor(altText)}>
<div class="image-wrapper {size}" style:background-color={stringToColor(altText)}>
<img
class="avatar"
alt={altText}
@ -33,6 +34,18 @@
border-radius: 6px;
width: 12px;
height: 12px;
&.small {
border-radius: 6px;
width: 12px;
height: 12px;
}
&.medium {
border-radius: 8px;
width: 16px;
height: 16px;
}
}
.image-wrapper > * {

View File

@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
const { children }: Props = $props();
</script>
<div class="avatar-grouping">
{@render children()}
</div>
<style lang="postcss">
.avatar-grouping {
display: flex;
:global(& > *) {
margin-right: -4px;
&:last-child {
margin-right: 0;
}
}
}
</style>

View File

@ -0,0 +1,14 @@
export function gravatarUrl(id: string | undefined | null): string | undefined {
if (id) return `https://www.gravatar.com/avatar/${id}?s=100&r=g&d=retro`;
}
export async function gravatarUrlFromEmail(email: string): Promise<string> {
const encoder = new TextEncoder();
const strippedEmail = email.toLocaleLowerCase().replaceAll(' ', '');
const data = encoder.encode(strippedEmail);
const hash = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hash));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
return gravatarUrl(hashHex) as string;
}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import Icon from '$lib/icon/Icon.svelte';
import { portal } from '$lib/utils/portal';
import Icon from '@gitbutler/ui/icon/Icon.svelte';
import type iconsJson from '@gitbutler/ui/icon/icons.json';
import type iconsJson from '$lib/icon/icons.json';
import type { Snippet } from 'svelte';
interface Props {

View File

@ -1,106 +1,114 @@
<script lang="ts">
import TimeAgo from '$lib/shared/TimeAgo.svelte';
import Icon from '@gitbutler/ui/icon/Icon.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';
import Icon from '$lib/icon/Icon.svelte';
import TimeAgo from '$lib/timeAgo/TimeAgo.svelte';
import { tooltip } from '$lib/utils/tooltip';
import { onMount, type Snippet } from 'svelte';
interface Props {
projectId: string;
branch: CombinedBranch;
onMouseDown?: () => void;
onFirstSeen?: () => void;
selected?: boolean;
title: string;
applied?: boolean;
pullRequestDetails?: { title: string };
lastCommitDetails?: { authorName: string; lastCommitAt: Date };
branchDetails?: { commitCount: number; linesAdded: number; linesRemoved: number };
remotes?: string[];
local?: boolean;
authorAvatars: Snippet;
}
const { projectId, branch }: Props = $props();
const {
onMouseDown = () => {},
onFirstSeen = () => {},
selected = false,
applied = false,
title,
pullRequestDetails,
lastCommitDetails,
branchDetails,
remotes = [],
local = false,
let href = $derived(getBranchLink(branch));
let selected = $state(false);
authorAvatars
}: Props = $props();
let intersectionTarget = $state<HTMLButtonElement>();
const observer = new IntersectionObserver(onFirstSeen);
$effect(() => {
selected = href ? $page.url.href.endsWith(href) : false;
// console.log(branch);
if (intersectionTarget) {
observer.observe(intersectionTarget);
}
});
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}`;
}
onMount(() => {
return () => {
observer.disconnect();
};
});
</script>
<button
class="branch"
class:selected
onmousedown={() => {
if (href) goto(href);
}}
>
<button class="branch" class:selected onmousedown={onMouseDown} bind:this={intersectionTarget}>
<h4 class="text-base-13 text-semibold branch-name">
{branch.displayName}
{title}
</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>
{@render authorAvatars()}
<div class="branch-remotes">
<!-- NEED API -->
{#if branch.remoteBranch}
{#each remotes as remote}
<div class="branch-tag tag-remote">
<span class="text-base-10 text-semibold">origin</span>
<span class="text-base-10 text-semibold">{remote}</span>
</div>
<!-- <div class="branch-tag tag-local">
{/each}
{#if local}
<div class="branch-tag tag-local">
<span class="text-base-10 text-semibold">local</span>
</div> -->
</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">
{#if pullRequestDetails}
<div use:tooltip={{ text: pullRequestDetails.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> -->
{#if applied}
<div class="branch-tag tag-applied">
<span class="text-base-10 text-semibold">applied</span>
</div>
{/if}
</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 lastCommitDetails}
<TimeAgo date={lastCommitDetails.lastCommitAt} />
by {lastCommitDetails.authorName}
{/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>
{#if branchDetails}
<div use:tooltip={'Number of commits'} class="branch-tag tag-commits">
<span class="text-base-10 text-semibold">{branchDetails.commitCount}</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 use:tooltip={'Code changes'} class="code-changes">
<span class="text-base-10 text-semibold">+{branchDetails.linesAdded}</span>
<span class="text-base-10 text-semibold">-{branchDetails.linesRemoved}</span>
</div>
{/if}
</div>
</div>
</button>
@ -136,26 +144,6 @@
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 {
@ -229,8 +217,9 @@
}
.branch-remotes {
margin-left: 6px;
display: flex;
gap: 2px;
gap: 6px;
}
.branch-name {

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { createTimeAgoStore } from '$lib/timeAgo/timeAgo';
interface Props {
date?: Date;
addSuffix?: boolean;
}
const { date, addSuffix }: Props = $props();
const store = $derived(createTimeAgoStore(date, addSuffix));
</script>
{#if store}
{$store}
{/if}

View File

@ -0,0 +1,34 @@
import Avatar from '$lib/avatar/Avatar.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: Avatar,
argTypes: {
size: {
control: 'select',
options: ['small', 'medium']
}
}
} satisfies Meta<Avatar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AvatarSmall: Story = {
args: {
srcUrl: 'https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c',
tooltipText: 'The avatar of bob',
altText: 'Bobs avatar',
size: 'small'
}
};
export const AvatarMedium: Story = {
args: {
srcUrl: 'https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c',
tooltipText: 'The avatar of bob',
altText: 'Bobs avatar',
size: 'medium'
}
};

View File

@ -0,0 +1,13 @@
import DemoAvatarGrouping from './DemoAvatarGrouping.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: DemoAvatarGrouping
} satisfies Meta<DemoAvatarGrouping>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AvatarGrouping: Story = {
args: {}
};

View File

@ -0,0 +1,25 @@
<script lang="ts">
import Avatar from '$lib/avatar/Avatar.svelte';
import AvatarGrouping from '$lib/avatar/AvatarGrouping.svelte';
</script>
<AvatarGrouping>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
</AvatarGrouping>

View File

@ -0,0 +1,73 @@
import DemoButton from './ButtonDemo.svelte';
import iconsJson from '$lib/icon/icons.json';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: DemoButton,
argTypes: {
disabled: {
control: 'boolean'
},
clickable: {
control: 'boolean'
},
loading: {
control: 'boolean'
},
size: {
control: 'select',
options: ['tag', 'button', 'cta']
},
style: {
control: 'select',
options: ['neutral', 'ghost', 'pop', 'success', 'error', 'warning', 'purple', undefined]
},
kind: {
control: 'select',
options: ['solid', 'soft', undefined]
},
outline: {
control: 'boolean'
},
dashed: {
control: 'boolean'
},
icon: {
control: 'select',
options: [undefined, ...Object.keys(iconsJson)]
}
}
} satisfies Meta<DemoButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ButtonDefalut: Story = {
args: {
contents: 'Testeroni',
size: 'button',
disabled: false,
clickable: true,
loading: false,
style: 'neutral',
kind: 'soft',
outline: false,
dashed: false,
icon: undefined
}
};
export const ButtonWithIcon: Story = {
args: {
contents: "Testeroni",
size: "button",
disabled: false,
clickable: true,
loading: false,
style: "pop",
kind: "solid",
outline: false,
dashed: false,
icon: "ai-small"
}
};

View File

@ -0,0 +1,12 @@
<script lang="ts">
import Button from '$lib/inputs/Button.svelte';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<Button> & { contents: string };
const { contents, ...args }: Props = $props();
</script>
<Button {...args}>
{contents}
</Button>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import iconsJson from '$lib/icon/icons.json';
import Button from '$lib/inputs/Button.svelte';
import Modal from '$lib/modal/Modal.svelte';
interface Props {
width?: 'default' | 'small' | 'large';
title?: string;
icon?: keyof typeof iconsJson;
}
const { ...args }: Props = $props();
let modal = $state<Modal>();
</script>
<Button on:click={() => modal?.show()}>Show</Button>
<Modal bind:this={modal} {...args}>
<p>Wonderful modal content</p>
{#snippet controls(close)}
<Button on:click={() => close()}>Close</Button>
{/snippet}
</Modal>

View File

@ -0,0 +1,28 @@
import DemoModal from './DemoModal.svelte';
import iconsJson from '$lib/icon/icons.json';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: DemoModal,
argTypes: {
width: {
control: 'select',
options: ['default', 'small', 'large']
},
title: { control: 'text' },
icon: {
control: 'select',
options: [undefined, ...Object.keys(iconsJson)]
}
}
} satisfies Meta<DemoModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ModalStory: Story = {
args: {
width: 'small',
title: 'This is a fantastic modal :D'
}
};

View File

@ -0,0 +1,50 @@
<script lang="ts">
import Avatar from '$lib/avatar/Avatar.svelte';
import AvatarGrouping from '$lib/avatar/AvatarGrouping.svelte';
import SidebarEntry from '$lib/sidebarEntry/SidebarEntry.svelte';
interface Props {
selected?: boolean;
title: string;
applied?: boolean;
pullRequestDetails?: { title: string };
// Storybook can give us pretty much anything under the sun for a date so we need to handle it
lastCommitDetails?: { authorName: string; lastCommitAt: any };
branchDetails?: { commitCount: number; linesAdded: number; linesRemoved: number };
remotes?: string[];
local?: boolean;
}
const { ...args }: Props = $props();
$effect.pre(() => {
if (args.lastCommitDetails) {
args.lastCommitDetails.lastCommitAt = new Date(args.lastCommitDetails.lastCommitAt);
}
});
</script>
<SidebarEntry {...args}>
{#snippet authorAvatars()}
<AvatarGrouping>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
<Avatar
srcUrl="https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c"
size="medium"
tooltipText="Bestest hamster"
altText="Bestest hamster"
/>
</AvatarGrouping>
{/snippet}
</SidebarEntry>

View File

@ -0,0 +1,107 @@
import DemoSidebarEntry from './DemoSidebarEntry.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
interface _Props {
selected?: boolean;
title: string;
applied?: boolean;
pullRequestDetails?: { title: string };
lastCommitDetails?: { authorName: string; lastCommitAt: Date };
branchDetails?: { commitCount: number; linesAdded: number; linesRemoved: number };
remotes?: string[];
local?: boolean;
}
const meta = {
component: DemoSidebarEntry,
argTypes: {
selected: { control: 'boolean' },
title: { control: 'text' },
applied: { control: 'boolean' },
pullRequestDetails: { control: 'object' },
lastCommitDetails: { control: 'object' },
branchDetails: { control: 'object' },
remotes: { control: 'object' },
local: { control: 'boolean' }
}
} satisfies Meta<DemoSidebarEntry>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SidebarEntry: Story = {
args: {
title: 'best branch ever',
selected: false,
applied: false,
pullRequestDetails: undefined,
lastCommitDetails: {
authorName: 'Caleb',
lastCommitAt: '2024-07-31T15:39:14.540Z'
},
branchDetails: {
commitCount: 4,
linesAdded: 35,
linesRemoved: 64
},
remotes: [],
local: true
},
argTypes: {}
};
export const SidebarEntryPr: Story = {
args: {
title: 'best branch ever',
selected: false,
applied: false,
lastCommitDetails: {
authorName: 'Caleb',
lastCommitAt: '2024-07-31T15:39:14.540Z'
},
branchDetails: {
commitCount: 4,
linesAdded: 35,
linesRemoved: 64
},
remotes: ['origin'],
local: true,
pullRequestDetails: {
title: 'bestest pr'
}
},
argTypes: {}
};
export const SidebarEntryInWorkspace: Story = {
args: {
title: 'best branch ever',
selected: false,
applied: true,
lastCommitDetails: {
authorName: 'Caleb',
lastCommitAt: '2024-07-31T15:39:14.540Z'
},
branchDetails: {
commitCount: 4,
linesAdded: 35,
linesRemoved: 64
},
remotes: ['origin'],
local: true,
pullRequestDetails: {
title: 'bestest pr'
}
},
argTypes: {}
};

View File

@ -0,0 +1,12 @@
<script lang="ts">
import TimeAgo from '$lib/timeAgo/TimeAgo.svelte';
interface Props {
date: number;
addSuffix?: boolean;
}
const { date, addSuffix }: Props = $props();
</script>
<TimeAgo date={new Date(date)} {addSuffix}></TimeAgo>

View File

@ -0,0 +1,28 @@
import DemoTimeAgo from './DemoTimeAgo.svelte';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
component: DemoTimeAgo,
argTypes: {
date: {
control: 'date'
}
}
} satisfies Meta<DemoTimeAgo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TimeAgoSuffixless: Story = {
args: {
date: 1721315627068,
addSuffix: false
}
};
export const TimeAgoWithSuffix: Story = {
args: {
date: 1721315627068,
addSuffix: true
}
};

View File

@ -373,6 +373,9 @@ importers:
cpy-cli:
specifier: ^5.0.0
version: 5.0.0
date-fns:
specifier: ^2.30.0
version: 2.30.0
postcss:
specifier: ^8.4.38
version: 8.4.39