feat: new stacking ui commit lines (#4972)

Co-authored-by: Pavel Laptev <pawellaptew@gmail.com>
This commit is contained in:
Nico Domino 2024-09-28 16:59:29 +02:00 committed by GitHub
parent 8b84f46df1
commit d10bbcf515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1773 additions and 109 deletions

View File

@ -12,6 +12,7 @@
import Dropzones from '$lib/branch/Dropzones.svelte';
import CommitDialog from '$lib/commit/CommitDialog.svelte';
import CommitList from '$lib/commit/CommitList.svelte';
import StackingCommitList from '$lib/commit/StackingCommitList.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import BranchFiles from '$lib/file/BranchFiles.svelte';
@ -240,12 +241,12 @@
{#if $stackingFeature}
{@const groups = groupCommitsByRef(branch.commits)}
{#each groups as group (group.ref)}
<div class="commit-group">
<div class="commit-group" class:stacking={$stackingFeature}>
{#if group.branchName}
<StackedBranchHeader upstreamName={group.branchName} />
<PullRequestCard upstreamName={group.branchName} />
{/if}
<CommitList
<StackingCommitList
localCommits={group.localCommits}
localAndRemoteCommits={group.remoteCommits}
integratedCommits={group.integratedCommits}
@ -256,6 +257,14 @@
/>
</div>
{/each}
{#if $integratedCommits.length === 0 && $localCommits.length > 0}
{@render pushButton({
disabled:
localCommitsConflicted ||
localAndRemoteCommitsConflicted ||
$localCommits.length === 0
})}
{/if}
{:else}
<CommitList
localCommits={$localCommits}
@ -268,11 +277,6 @@
{pushButton}
/>
{/if}
{#if $stackingFeature}
{@render pushButton({
disabled: localCommitsConflicted || localAndRemoteCommitsConflicted
})}
{/if}
</div>
</div>
</ScrollableContainer>
@ -325,6 +329,13 @@
padding: 12px;
}
.branch-card__files.card,
.no-changes.card,
.new-branch.card {
border-radius: 0 0 var(--radius-m) var(--radius-m) !important;
}
/* Stacking */
.card-no-stacking {
flex: 1;
display: flex;
@ -338,6 +349,13 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.commit-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.branch-card__files {
@ -353,6 +371,15 @@
padding: 12px;
}
.card-no-stacking {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
background: var(--clr-bg-1);
}
.new-branch,
.no-changes {
flex-grow: 1;
@ -383,12 +410,4 @@
height: 100%;
background-color: var(--clr-border-2);
}
.commit-group {
margin: 10px 0;
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
background: var(--clr-bg-1);
overflow: hidden;
}
</style>

View File

@ -219,9 +219,10 @@
</div>
</div>
{:else}
<div class="header__wrapper">
<div class="header__wrapper" class:header__wrapper--stacking={$stackingFeature}>
<div
class="header card"
class:header_card--stacking={$stackingFeature}
class:header_target-branch={branch.selectedForChanges}
class:header_target-branch-animation={isTargetBranchAnimated && branch.selectedForChanges}
>
@ -337,6 +338,18 @@
top: 12px;
padding-bottom: 8px;
}
.header__wrapper--stacking {
padding-bottom: unset !important;
& .header__info-wrapper .draggable {
height: auto;
}
}
.header_card--stacking {
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
border-bottom-width: 0px;
}
.header {
z-index: var(--z-lifted);
position: relative;
@ -428,6 +441,7 @@
.draggable {
display: flex;
height: fit-content;
align-items: center;
cursor: grab;
padding: 2px 2px 0 0;
color: var(--clr-scale-ntrl-50);

View File

@ -5,7 +5,6 @@
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
@ -92,17 +91,6 @@
let createRefModal: Modal;
let createRefName = $baseBranch.remoteName + '/';
function openCreateRefModal(e: Event, commit: DetailedCommit | Commit) {
e.stopPropagation();
createRefModal.show(commit);
}
function pushCommitRef(commit: DetailedCommit) {
if (branch && commit.remoteRef) {
branchController.pushChangeReference(branch.id, commit.remoteRef);
}
}
function openCommitMessageModal(e: Event) {
e.stopPropagation();
@ -267,7 +255,8 @@
? {
label: commit.descriptionTitle,
sha: commitShortSha,
dateAndAuthor: getTimeAndAuthor(),
date: getTimeAgo(commit.createdAt),
authorImgUrl: commit.author.gravatarUrl,
commitType: type,
data: new DraggableCommit(commit.branchId, commit, isHeadCommit),
viewportId: 'board-viewport'
@ -407,26 +396,6 @@
icon="edit-small"
onclick={openCommitMessageModal}>Edit message</Button
>
{#if $stackingFeature && commit instanceof DetailedCommit && !commit.remoteRef}
<Button
size="tag"
style="ghost"
outline
icon="branch"
onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button
>
{/if}
{#if $stackingFeature && commit instanceof DetailedCommit && commit.remoteRef}
<Button
size="tag"
style="ghost"
outline
icon="remote"
onclick={() => {
pushCommitRef(commit);
}}>Push ref</Button
>
{/if}
{/if}
{#if canEdit() && project.succeedingRebases}
<Button size="tag" style="ghost" outline onclick={editPatch}>

View File

@ -119,6 +119,7 @@
padding: 14px;
background: var(--clr-bg-1);
border-top: 1px solid var(--clr-border-2);
border-radius: 0 0 var(--radius-m) var(--radius-m) !important;
transition: background-color var(--transition-medium);
}

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { CommitDragActions, CommitDragActionsFactory } from '$lib/commits/dragActions';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import CardOverlay from '$lib/dropzone/CardOverlay.svelte';
import Dropzone from '$lib/dropzone/Dropzone.svelte';
import { getContext, maybeGetContextStore } from '$lib/utils/context';
@ -40,9 +41,13 @@
{hovered}
{activated}
label="Amend commit"
extraPaddings={{
left: 4
}}
extraPaddings={$stackingFeature
? {
left: 4
}
: {
left: 4
}}
/>
{/snippet}
</Dropzone>
@ -57,9 +62,13 @@
{hovered}
{activated}
label="Squash commit"
extraPaddings={{
left: 4
}}
extraPaddings={$stackingFeature
? {
left: -4
}
: {
left: 4
}}
/>
{/snippet}
</Dropzone>

View File

@ -5,7 +5,6 @@
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { transformAnyCommit } from '$lib/commitLines/transformers';
import InsertEmptyCommitAction from '$lib/components/InsertEmptyCommitAction.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import {
ReorderDropzoneManagerFactory,
type ReorderDropzone
@ -69,9 +68,7 @@
const mappedLocalCommits = $derived(
localCommits.length > 0
? !$stackingFeature
? [...localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
: localCommits.map(transformAnyCommit)
? [...localCommits.map(transformAnyCommit), { id: LineSpacer.Local }]
: []
);
const mappedLocalAndRemoteCommits = $derived(
@ -148,7 +145,7 @@
{/snippet}
{#if hasCommits || hasRemoteCommits}
<div class="commits" class:stacked={$stackingFeature}>
<div class="commits">
<!-- UPSTREAM COMMITS -->
{#if remoteCommits.length > 0}
@ -235,7 +232,7 @@
on:click={() => insertBlankCommit(commit.id, 'below')}
/>
{/each}
{#if !$stackingFeature && pushButton}
{#if pushButton}
<CommitAction bottomBorder={hasRemoteCommits}>
{#snippet lines()}
<LineGroup lineGroup={lineManager.get(LineSpacer.Local)} topHeightPx={0} />
@ -331,8 +328,9 @@
{/key}
</div>
<div class="base-row__content">
<span class="text-11 base-row__text"
>Base commit <button
<span class="text-11 base-row__text">
Base commit
<button
class="base-row__commit-link"
onclick={async () => await goto(`/${project.id}/base`)}
>
@ -365,10 +363,6 @@
--avatar-top: 16px;
}
.commits.stacked {
border-top: none;
}
/* BASE ROW */
.base-row-container {

View File

@ -0,0 +1,647 @@
<script lang="ts">
import CommitContextMenu from './CommitContextMenu.svelte';
import { Project } from '$lib/backend/projects';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
import { ModeService } from '$lib/modes/service';
import TextBox from '$lib/shared/TextBox.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import { getContext, getContextStore, maybeGetContext } from '$lib/utils/context';
import { openExternalUrl } from '$lib/utils/url';
import { BranchController } from '$lib/vbranches/branchController';
import { createCommitStore } from '$lib/vbranches/contexts';
import { listRemoteCommitFiles } from '$lib/vbranches/remoteCommits';
import {
Commit,
DetailedCommit,
RemoteFile,
VirtualBranch,
type CommitStatus
} from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import Icon from '@gitbutler/ui/Icon.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import { getTimeAgo } from '@gitbutler/ui/utils/timeAgo';
import { type Snippet } from 'svelte';
interface Props {
branch?: VirtualBranch | undefined;
commit: DetailedCommit | Commit;
commitUrl?: string | undefined;
isHeadCommit?: boolean;
isUnapplied?: boolean;
last?: boolean;
type: CommitStatus;
lines?: Snippet | undefined;
filesToggleable?: boolean;
}
const {
branch = undefined,
commit,
commitUrl = undefined,
isHeadCommit = false,
isUnapplied = false,
last = false,
type,
lines = undefined,
filesToggleable = true
}: Props = $props();
const branchController = getContext(BranchController);
const baseBranch = getContextStore(BaseBranch);
const project = getContext(Project);
const modeService = maybeGetContext(ModeService);
const commitStore = createCommitStore(commit);
$effect(() => {
commitStore.set(commit);
});
const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || '');
let draggableCommitElement = $state<HTMLElement>();
let contextMenu = $state<CommitContextMenu>();
let files = $state<RemoteFile[]>([]);
let showDetails = $state(false);
async function loadFiles() {
files = await listRemoteCommitFiles(project.id, commit.id);
}
function toggleFiles() {
if (!filesToggleable) return;
showDetails = !showDetails;
if (showDetails) loadFiles();
}
function onKeyup(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
toggleFiles();
}
}
function undoCommit(commit: DetailedCommit | Commit) {
if (!branch || !$baseBranch) {
console.error('Unable to undo commit');
return;
}
branchController.undoCommit(branch.id, commit.id);
}
let isUndoable = commit instanceof DetailedCommit;
let commitMessageModal: Modal;
let commitMessageValid = $state(false);
let description = $state('');
let createRefModal: Modal;
let createRefName = $state($baseBranch.remoteName + '/');
function openCreateRefModal(e: Event, commit: DetailedCommit | Commit) {
e.stopPropagation();
createRefModal.show(commit);
}
function pushCommitRef(commit: DetailedCommit) {
if (branch && commit.remoteRef) {
branchController.pushChangeReference(branch.id, commit.remoteRef);
}
}
function openCommitMessageModal(e: Event) {
e.stopPropagation();
description = commit.description;
commitMessageModal.show();
}
function submitCommitMessageModal() {
commit.description = description;
if (branch) {
branchController.updateCommitMessage(branch.id, commit.id, description);
}
commitMessageModal.close();
}
const commitShortSha = commit.id.substring(0, 7);
let dragDirection: 'up' | 'down' | undefined = $state();
let isDragTargeted = $state(false);
function canEdit() {
if (isUnapplied) return false;
if (!modeService) return false;
if (!branch) return false;
return true;
}
async function editPatch() {
if (!canEdit()) return;
modeService!.enterEditMode(commit.id, branch!.refname);
}
const conflicted = $derived(commit instanceof DetailedCommit && commit.conflicted);
</script>
<Modal bind:this={commitMessageModal} width="small" onSubmit={submitCommitMessageModal}>
{#snippet children(_, close)}
<CommitMessageInput
focusOnMount
bind:commitMessage={description}
bind:valid={commitMessageValid}
isExpanded={true}
cancel={close}
commit={submitCommitMessageModal}
/>
{/snippet}
{#snippet controls(close)}
<Button style="ghost" outline onclick={close}>Cancel</Button>
<Button style="neutral" type="submit" kind="solid" grow disabled={!commitMessageValid}>
Submit
</Button>
{/snippet}
</Modal>
<Modal bind:this={createRefModal} width="small">
{#snippet children(commit)}
<TextBox label="Remote branch name" id="newRemoteName" bind:value={createRefName} focus />
<Button
style="pop"
kind="solid"
onclick={() => {
branchController.createChangeReference(
branch?.id || '',
'refs/remotes/' + createRefName,
commit.changeId
);
createRefModal.close();
}}
>
Ok
</Button>
{/snippet}
{#snippet controls(close)}
<Button style="ghost" outline type="reset" onclick={close}>Cancel</Button>
{/snippet}
</Modal>
{#if draggableCommitElement}
<CommitContextMenu
bind:this={contextMenu}
targetElement={draggableCommitElement}
{commit}
{commitUrl}
/>
{/if}
<div
class="commit-row"
class:is-commit-open={showDetails}
class:is-last={last}
onclick={toggleFiles}
onkeyup={onKeyup}
role="button"
tabindex="0"
ondragenter={() => {
isDragTargeted = true;
}}
ondragleave={() => {
isDragTargeted = false;
}}
ondrop={() => {
isDragTargeted = false;
}}
ondrag={(e) => {
const target = e.target as HTMLElement;
const targetHeight = target.offsetHeight;
const targetTop = target.getBoundingClientRect().top;
const mouseY = e.clientY;
const isTop = mouseY < targetTop + targetHeight / 2;
dragDirection = isTop ? 'up' : 'down';
}}
use:draggableCommit={commit instanceof DetailedCommit && !isUnapplied && type !== 'integrated'
? {
label: commit.descriptionTitle,
sha: commitShortSha,
date: getTimeAgo(commit.createdAt),
authorImgUrl: commit.author.gravatarUrl,
commitType: type,
data: new DraggableCommit(commit.branchId, commit, isHeadCommit),
viewportId: 'board-viewport'
}
: nonDraggable()}
>
{#if dragDirection && isDragTargeted}
<div
class="pseudo-reorder-zone"
class:top={dragDirection === 'up'}
class:bottom={dragDirection === 'down'}
class:is-last={last}
></div>
{/if}
{#if lines}
<div>
{@render lines()}
</div>
{/if}
<div class="commit-card" class:is-last={last}>
<!-- GENERAL INFO -->
<div
bind:this={draggableCommitElement}
class="commit__header"
role="button"
tabindex="-1"
oncontextmenu={(e) => {
contextMenu?.open(e);
}}
>
{#if !isUnapplied}
{#if type === 'local' || type === 'localAndRemote'}
<div class="commit__drag-icon">
<Icon name="draggable-narrow" />
</div>
{/if}
{/if}
{#if isUndoable && !commit.descriptionTitle}
<span class="text-13 text-body text-semibold commit__empty-title">empty commit message</span
>
{:else}
<h5 class="text-13 text-body text-semibold commit__title" class:truncate={!showDetails}>
{commit.descriptionTitle}
</h5>
<div class="text-11 commit__subtitle">
{#if commit.isSigned}
<Tooltip text="Signed">
<div class="commit__signed">
<Icon name="success-outline-small" />
</div>
</Tooltip>
<span class="commit__subtitle-divider"></span>
{/if}
{#if conflicted}
<Tooltip
text={"Conflicted commits must be resolved before they can be ammended or squashed.\nPlease resolve conflicts using the 'Resolve conflicts' button"}
>
<div class="commit__conflicted">
<Icon name="warning-small" />
Conflicted
</div>
</Tooltip>
<span class="commit__subtitle-divider"></span>
{/if}
<Tooltip text={commit.author.name}>
<img class="commit__subtitle-avatar" src={commit.author.gravatarUrl} alt="" />
</Tooltip>
<span class="commit__subtitle-divider"></span>
<button
class="commit__subtitle-btn commit__subtitle-btn_dashed"
onclick={(e) => {
e.stopPropagation();
copyToClipboard(commit.id);
}}
>
{commitShortSha}
<div class="commit__subtitle-btn__icon">
<Icon name="copy-small" />
</div>
</button>
{#if showDetails && commitUrl}
<span class="commit__subtitle-divider"></span>
<button
class="commit__subtitle-btn"
onclick={(e) => {
e.stopPropagation();
if (commitUrl) openExternalUrl(commitUrl);
}}
>
<span>Open</span>
<div class="commit__subtitle-btn__icon">
<Icon name="open-link" />
</div>
</button>
{/if}
<span class="commit__subtitle-divider"></span>
<span>{getTimeAgo(commit.createdAt)}</span>
</div>
{/if}
</div>
<!-- HIDDEN -->
{#if showDetails}
{#if commit.descriptionBody || isUndoable}
<div class="commit__details">
{#if commit.descriptionBody}
<span class="commit__description text-12 text-body">
{commit.descriptionBody}
</span>
{/if}
{#if isUndoable}
<div class="commit__actions hide-native-scrollbar">
{#if isUndoable}
{#if !conflicted}
<Button
size="tag"
style="ghost"
outline
icon="undo-small"
onclick={(e: MouseEvent) => {
currentCommitMessage.set(commit.description);
e.stopPropagation();
undoCommit(commit);
}}
>Undo</Button
>
{/if}
<Button
size="tag"
style="ghost"
outline
icon="edit-small"
onclick={openCommitMessageModal}>Edit message</Button
>
{#if commit instanceof DetailedCommit && !commit.remoteRef}
<Button
size="tag"
style="ghost"
outline
icon="virtual-branch-small"
onclick={(e: Event) => {openCreateRefModal(e, commit)}}>Create ref</Button
>
{/if}
{#if commit instanceof DetailedCommit && commit.remoteRef}
<Button
size="tag"
style="ghost"
outline
icon="remote"
onclick={() => {
pushCommitRef(commit);
}}>Push ref</Button
>
{/if}
{/if}
{#if canEdit() && project.succeedingRebases}
<Button size="tag" style="ghost" outline onclick={editPatch}>
{#if conflicted}
Resolve conflicts
{:else}
Edit patch
{/if}
</Button>
{/if}
</div>
{/if}
</div>
{/if}
<div class="files-container">
<BranchFilesList
allowMultiple={!isUnapplied && type !== 'remote'}
{files}
{isUnapplied}
readonly={type === 'remote' || isUnapplied}
/>
</div>
{/if}
</div>
</div>
<style lang="postcss">
.commit-row {
position: relative;
display: flex;
gap: 10px;
background-color: var(--clr-bg-1);
transition: background-color var(--transition-fast);
&:focus {
outline: none;
}
&:not(.is-commit-open) {
&:hover {
background-color: var(--clr-bg-1-muted);
}
}
&:not(.is-last) {
border-bottom: 1px solid var(--clr-border-2);
}
}
.commit-card {
display: flex;
position: relative;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.commit__conflicted {
display: flex;
align-items: center;
gap: 4px;
color: var(--clr-core-err-40);
}
/* HEADER */
.commit__header {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 14px 14px 0;
&:hover {
& .commit__drag-icon {
opacity: 1;
}
}
}
.commit__drag-icon {
pointer-events: none;
position: absolute;
display: flex;
top: 4px;
right: 2px;
color: var(--clr-text-3);
opacity: 0;
transition: opacity var(--transition-fast);
}
.commit__title {
flex: 1;
display: block;
color: var(--clr-text-1);
width: 100%;
}
.commit__description {
color: var(--clr-text-2);
white-space: pre-wrap;
user-select: text;
cursor: text;
}
.commit__empty-title {
color: var(--clr-text-3);
}
.commit__subtitle {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
color: var(--clr-text-2);
overflow: hidden;
& > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.commit__signed {
display: flex;
}
/* SUBTITLE LINK BUTTON */
.commit__subtitle-btn {
flex-shrink: 0;
display: flex;
align-items: center;
text-decoration-line: underline;
text-underline-offset: 2px;
transition: color var(--transition-fast);
&:hover {
color: var(--clr-text-1);
& .commit__subtitle-btn__icon {
width: var(--size-icon);
opacity: 1;
margin-left: 2px;
transform: scale3d(1, 1, 1);
}
}
}
.commit__subtitle-btn_dashed {
text-decoration-style: dashed;
}
.commit__subtitle-btn__icon {
display: flex;
width: 0;
opacity: 0;
margin-left: 0;
transform: scale3d(0.6, 0.6, 0.6); /* CSS glitch fix */
transition:
width var(--transition-medium),
opacity var(--transition-fast),
color var(--transition-fast),
transform var(--transition-medium),
margin var(--transition-fast);
}
.commit__subtitle-avatar {
width: 12px;
height: 12px;
border-radius: 50%;
}
/* DIVIDER - DOT SYMBOL */
.commit__subtitle-divider {
opacity: 0.4;
}
/* DETAILS */
.commit__details {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.commit__actions {
overflow: visible;
display: flex;
gap: 4px;
overflow-x: auto;
}
/* FILES */
.files-container {
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
margin-right: 14px;
margin-bottom: 14px;
overflow: hidden;
}
/* MODIFIERS */
.is-commit-open {
& .commit__subtitle-btn__icon {
width: var(--size-icon);
opacity: 1;
margin-left: 2px;
transform: scale3d(1, 1, 1);
}
}
/* PSUEDO DROPZONE */
.pseudo-reorder-zone {
z-index: var(--z-lifted);
position: absolute;
height: 2px;
width: 100%;
background-color: var(--clr-theme-pop-element);
}
.pseudo-reorder-zone.top {
top: -1px;
}
.pseudo-reorder-zone.bottom {
bottom: -1px;
}
.pseudo-reorder-zone.bottom.is-last {
bottom: -6px;
}
</style>

View File

@ -0,0 +1,59 @@
<script lang="ts">
import { CommitDragActions, CommitDragActionsFactory } from '$lib/commits/dragActions';
import CardOverlay from '$lib/dropzone/CardOverlay.svelte';
import Dropzone from '$lib/dropzone/Dropzone.svelte';
import { getContext, maybeGetContextStore } from '$lib/utils/context';
import { Commit, VirtualBranch, DetailedCommit } from '$lib/vbranches/types';
import type { Snippet } from 'svelte';
const commitDragActionsFactory = getContext(CommitDragActionsFactory);
interface Props {
commit: DetailedCommit | Commit;
children: Snippet;
}
const { commit, children }: Props = $props();
const branch = maybeGetContextStore(VirtualBranch);
const actions = $derived<CommitDragActions | undefined>(
$branch && commitDragActionsFactory.build($branch, commit)
);
</script>
<div class="dropzone-wrapper">
{#if actions}
{@render ammendDropzone()}
{:else}
{@render children()}
{/if}
</div>
<!-- We require the dropzones to be nested -->
{#snippet ammendDropzone()}
<Dropzone accepts={actions!.acceptAmend.bind(actions)} ondrop={actions!.onAmend.bind(actions)}>
{@render squashDropzone()}
{#snippet overlay({ hovered, activated })}
<CardOverlay {hovered} {activated} label="Amend commit" />
{/snippet}
</Dropzone>
{/snippet}
{#snippet squashDropzone()}
<Dropzone accepts={actions!.acceptSquash.bind(actions)} ondrop={actions!.onSquash.bind(actions)}>
{@render children()}
{#snippet overlay({ hovered, activated })}
<CardOverlay {hovered} {activated} label="Squash commit" />
{/snippet}
</Dropzone>
{/snippet}
<style>
.dropzone-wrapper {
position: relative;
width: 100%;
}
</style>

View File

@ -0,0 +1,303 @@
<script lang="ts">
import CommitAction from './CommitAction.svelte';
import StackingCommitCard from './StackingCommitCard.svelte';
import StackingCommitDragItem from './StackingCommitDragItem.svelte';
import { BaseBranch } from '$lib/baseBranch/baseBranch';
import { transformAnyCommit } from '$lib/commitLines/transformers';
import InsertEmptyCommitAction from '$lib/components/InsertEmptyCommitAction.svelte';
import {
ReorderDropzoneManagerFactory,
type ReorderDropzone
} from '$lib/dragging/reorderDropzoneManager';
import Dropzone from '$lib/dropzone/Dropzone.svelte';
import LineOverlay from '$lib/dropzone/LineOverlay.svelte';
import { getGitHost } from '$lib/gitHost/interface/gitHost';
import { getContext } from '$lib/utils/context';
import { getContextStore } from '$lib/utils/context';
import { BranchController } from '$lib/vbranches/branchController';
import { Commit, DetailedCommit, VirtualBranch } from '$lib/vbranches/types';
import Button from '@gitbutler/ui/Button.svelte';
import Line from '@gitbutler/ui/commitLinesStacking/Line.svelte';
import { LineManagerFactory } from '@gitbutler/ui/commitLinesStacking/lineManager';
import type { Snippet } from 'svelte';
interface Props {
localCommits: DetailedCommit[];
localAndRemoteCommits: DetailedCommit[];
integratedCommits: DetailedCommit[];
remoteCommits: Commit[];
isUnapplied: boolean;
pushButton?: Snippet<[{ disabled: boolean }]>;
localCommitsConflicted: boolean;
localAndRemoteCommitsConflicted: boolean;
}
const {
localCommits,
localAndRemoteCommits,
integratedCommits,
remoteCommits,
isUnapplied,
pushButton,
localAndRemoteCommitsConflicted
}: Props = $props();
const branch = getContextStore(VirtualBranch);
const baseBranch = getContextStore(BaseBranch);
const branchController = getContext(BranchController);
const lineManagerFactory = getContext(LineManagerFactory);
const reorderDropzoneManagerFactory = getContext(ReorderDropzoneManagerFactory);
const gitHost = getGitHost();
// TODO: Why does eslint-svelte-plugin complain about enum?
// eslint-disable-next-line svelte/valid-compile
enum LineSpacer {
Remote = 'remote-spacer',
Local = 'local-spacer',
LocalAndRemote = 'local-and-remote-spacer'
}
const mappedRemoteCommits = $derived(
remoteCommits.length > 0
? [...remoteCommits.map(transformAnyCommit), { id: LineSpacer.Remote }]
: []
);
const mappedLocalCommits = $derived(
localCommits.length > 0 ? localCommits.map(transformAnyCommit) : []
);
const mappedLocalAndRemoteCommits = $derived(
localAndRemoteCommits.length > 0
? [...localAndRemoteCommits.map(transformAnyCommit), { id: LineSpacer.LocalAndRemote }]
: []
);
const lineManager = $derived(
lineManagerFactory.build({
remoteCommits: mappedRemoteCommits,
localCommits: mappedLocalCommits,
localAndRemoteCommits: mappedLocalAndRemoteCommits,
integratedCommits: integratedCommits.map(transformAnyCommit)
})
);
const hasCommits = $derived($branch.commits && $branch.commits.length > 0);
const headCommit = $derived($branch.commits.at(0));
const hasRemoteCommits = $derived(remoteCommits.length > 0);
const reorderDropzoneManager = $derived(
reorderDropzoneManagerFactory.build($branch, [...localCommits, ...localAndRemoteCommits])
);
let isIntegratingCommits = $state(false);
function insertBlankCommit(commitId: string, location: 'above' | 'below' = 'below') {
if (!$branch || !$baseBranch) {
console.error('Unable to insert commit');
return;
}
branchController.insertBlankCommit($branch.id, commitId, location === 'above' ? -1 : 1);
}
function getReorderDropzoneOffset({
isFirst = false,
isMiddle = false,
isLast = false
}: {
isFirst?: boolean;
isMiddle?: boolean;
isLast?: boolean;
}) {
if (isFirst) return 12;
if (isMiddle) return 6;
if (isLast) return 0;
return 0;
}
</script>
{#snippet reorderDropzone(dropzone: ReorderDropzone, yOffsetPx: number)}
<Dropzone accepts={dropzone.accepts.bind(dropzone)} ondrop={dropzone.onDrop.bind(dropzone)}>
{#snippet overlay({ hovered, activated })}
<LineOverlay {hovered} {activated} {yOffsetPx} />
{/snippet}
</Dropzone>
{/snippet}
{#if hasCommits || hasRemoteCommits}
<div class="commits">
<!-- UPSTREAM COMMITS -->
{#if remoteCommits.length > 0}
<!-- To make the sticky position work, commits should be wrapped in a div -->
<div class="commits-group">
{#each remoteCommits as commit, idx (commit.id)}
<StackingCommitCard
type="remote"
branch={$branch}
{commit}
{isUnapplied}
last={idx === remoteCommits.length - 1}
commitUrl={$gitHost?.commitUrl(commit.id)}
isHeadCommit={commit.id === headCommit?.id}
>
{#snippet lines()}
<Line line={lineManager.get(commit.id)} />
{/snippet}
</StackingCommitCard>
{/each}
<CommitAction>
{#snippet lines()}
<Line line={lineManager.get(LineSpacer.Remote)} />
{/snippet}
{#snippet action()}
<Button
style="warning"
kind="solid"
loading={isIntegratingCommits}
onclick={async () => {
isIntegratingCommits = true;
try {
await branchController.mergeUpstream($branch.id);
} catch (e) {
console.error(e);
} finally {
isIntegratingCommits = false;
}
}}>Integrate upstream</Button
>
{/snippet}
</CommitAction>
</div>
{/if}
<!-- LOCAL COMMITS -->
{#if localCommits.length > 0}
<div class="commits-group">
<InsertEmptyCommitAction
isFirst
on:click={() => insertBlankCommit($branch.head, 'above')}
/>
{@render reorderDropzone(
reorderDropzoneManager.topDropzone,
getReorderDropzoneOffset({ isFirst: true })
)}
{#each localCommits as commit, idx (commit.id)}
<StackingCommitDragItem {commit}>
<StackingCommitCard
{commit}
{isUnapplied}
type="local"
branch={$branch}
last={idx === localCommits.length - 1}
isHeadCommit={commit.id === headCommit?.id}
>
{#snippet lines()}
<Line line={lineManager.get(commit.id)} />
{/snippet}
</StackingCommitCard>
</StackingCommitDragItem>
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isLast: idx + 1 === localCommits.length,
isMiddle: idx + 1 === localCommits.length
})
)}
<InsertEmptyCommitAction
isLast={idx + 1 === localCommits.length}
on:click={() => insertBlankCommit(commit.id, 'below')}
/>
{/each}
</div>
{/if}
<!-- LOCAL AND REMOTE COMMITS -->
{#if localAndRemoteCommits.length > 0}
<div class="commits-group">
{#each localAndRemoteCommits as commit, idx (commit.id)}
<StackingCommitDragItem {commit}>
<StackingCommitCard
{commit}
{isUnapplied}
type="localAndRemote"
branch={$branch}
last={idx === localAndRemoteCommits.length - 1}
isHeadCommit={commit.id === headCommit?.id}
commitUrl={$gitHost?.commitUrl(commit.id)}
>
{#snippet lines()}
<Line line={lineManager.get(commit.id)} />
{/snippet}
</StackingCommitCard>
</StackingCommitDragItem>
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isMiddle: idx + 1 === localAndRemoteCommits.length
})
)}
<InsertEmptyCommitAction
isLast={idx + 1 === localAndRemoteCommits.length}
on:click={() => insertBlankCommit(commit.id, 'below')}
/>
{/each}
{#if remoteCommits.length > 0 && localCommits.length === 0 && pushButton}
<CommitAction>
{#snippet lines()}
<Line line={lineManager.get(LineSpacer.LocalAndRemote)} />
{/snippet}
{#snippet action()}
{@render pushButton({ disabled: localAndRemoteCommitsConflicted })}
{/snippet}
</CommitAction>
{/if}
</div>
{/if}
<!-- INTEGRATED COMMITS -->
{#if integratedCommits.length > 0}
<div class="commits-group">
{#each integratedCommits as commit, idx (commit.id)}
<StackingCommitCard
{commit}
{isUnapplied}
type="integrated"
branch={$branch}
isHeadCommit={commit.id === headCommit?.id}
last={idx === integratedCommits.length - 1}
commitUrl={$gitHost?.commitUrl(commit.id)}
>
{#snippet lines()}
<Line line={lineManager.get(commit.id)} />
{/snippet}
</StackingCommitCard>
{/each}
</div>
{/if}
</div>
{/if}
<style lang="postcss">
.commits {
position: relative;
display: flex;
flex-direction: column;
background-color: var(--clr-bg-2);
border-radius: var(--radius-m);
overflow: hidden;
border: 1px solid var(--clr-border-2);
}
.commits-group {
border-bottom: 1px solid var(--clr-border-2);
&:last-child {
border-bottom: none;
}
}
</style>

View File

@ -10,7 +10,8 @@ export interface DraggableConfig {
readonly label?: string;
readonly filePath?: string;
readonly sha?: string;
readonly dateAndAuthor?: string;
readonly date?: string;
readonly authorImgUrl?: string;
readonly commitType?: CommitStatus;
readonly data?: Draggable | Promise<Draggable>;
readonly viewportId?: string;
@ -197,13 +198,20 @@ export function createCommitElement(
commitType: CommitStatus | undefined,
label: string | undefined,
sha: string | undefined,
dateAndAuthor: string | undefined
date: string | undefined,
authorImgUrl: string | undefined
): HTMLDivElement {
const cardEl = createElement('div', ['draggable-commit']) as HTMLDivElement;
const labelEl = createElement('span', ['text-13', 'text-bold'], label || 'Empty commit');
const infoEl = createElement('div', ['draggable-commit-info', 'text-11']);
const authorImgEl = createElement(
'img',
['draggable-commit-author-img'],
undefined,
authorImgUrl
);
const shaEl = createElement('span', ['draggable-commit-info-text'], sha);
const dateAndAuthorEl = createElement('span', ['draggable-commit-info-text'], dateAndAuthor);
const dateAndAuthorEl = createElement('span', ['draggable-commit-info-text'], date);
if (commitType) {
const indicatorClass = `draggable-commit-${commitType}`;
@ -211,6 +219,7 @@ export function createCommitElement(
}
cardEl.appendChild(labelEl);
infoEl.appendChild(authorImgEl);
infoEl.appendChild(shaEl);
infoEl.appendChild(dateAndAuthorEl);
cardEl.appendChild(infoEl);
@ -219,7 +228,7 @@ export function createCommitElement(
export function draggableCommit(node: HTMLElement, initialOpts: DraggableConfig) {
function createClone(opts: DraggableConfig) {
return createCommitElement(opts.commitType, opts.label, opts.sha, opts.dateAndAuthor);
return createCommitElement(opts.commitType, opts.label, opts.sha, opts.date, opts.authorImgUrl);
}
return setupDragHandlers(node, initialOpts, createClone, {
handlerWidth: true

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { maybeGetContextStore } from '$lib/utils/context';
import { SelectedOwnership } from '$lib/vbranches/ownership';
import Badge from '@gitbutler/ui/Badge.svelte';
@ -6,9 +7,13 @@
import type { AnyFile } from '$lib/vbranches/types';
import type { Writable } from 'svelte/store';
export let title: string;
export let files: AnyFile[];
export let showCheckboxes = false;
interface Props {
title: string;
files: AnyFile[];
showCheckboxes?: boolean;
}
const { title, files, showCheckboxes = false }: Props = $props();
const selectedOwnership: Writable<SelectedOwnership> | undefined =
maybeGetContextStore(SelectedOwnership);
@ -41,11 +46,11 @@
return false;
}
$: indeterminate = selectedOwnership ? isIndeterminate($selectedOwnership) : false;
$: checked = isAllChecked($selectedOwnership);
const indeterminate = $derived(selectedOwnership ? isIndeterminate($selectedOwnership) : false);
const checked = $derived(isAllChecked($selectedOwnership));
</script>
<div class="header">
<div class="header" class:stacking={$stackingFeature}>
<div class="header__left">
{#if showCheckboxes && files.length > 1}
<Checkbox
@ -79,6 +84,10 @@
border-bottom: none;
border-radius: var(--radius-m) var(--radius-m) 0 0;
background-color: var(--clr-bg-1);
&.stacking {
background-color: transparent !important;
}
}
.header__title {
display: flex;

View File

@ -49,7 +49,7 @@
<div id={`file-${file.id}`} class="file-card" class:card={isCard}>
<FileCardHeader {file} {isFileLocked} on:close />
{#if conflicted}
<div class="mb-2 bg-red-500 px-2 py-0 font-bold text-white">
<div class="file-card__resolved-btn">
<button
class="font-bold text-white"
onclick={async () => await branchController.markResolved(file.path)}
@ -104,4 +104,10 @@
max-height: 100%;
flex-grow: 1;
}
.file-card__resolved-btn {
margin-bottom: 0.25rem;
background-color: var(--clr-theme-err-soft);
padding: 0.5rem;
}
</style>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import FileContextMenu from './FileContextMenu.svelte';
import { stackingFeature } from '$lib/config/uiFeatureFlags';
import { draggableChips } from '$lib/dragging/draggable';
import { DraggableFile } from '$lib/dragging/draggables';
import { itemsSatisfy } from '$lib/utils/array';
@ -134,6 +135,7 @@
fileName={file.filename}
filePath={file.path}
fileStatus={computeFileStatus(file)}
stacking={$stackingFeature}
{selected}
{showCheckbox}
{checked}
@ -142,6 +144,7 @@
{onclick}
{onkeydown}
locked={file.locked}
conflicted={file.conflicted}
{lockText}
oncheck={(e) => {
const isChecked = e.currentTarget.checked;

View File

@ -36,6 +36,7 @@
import * as events from '$lib/utils/events';
import { unsubscribe } from '$lib/utils/unsubscribe';
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
import { LineManagerFactory as StackingLineManagerFactory } from '@gitbutler/ui/commitLinesStacking/lineManager';
import { onMount, setContext, type Snippet } from 'svelte';
import { Toaster } from 'svelte-french-toast';
import type { LayoutData } from './$types';
@ -61,6 +62,8 @@
setContext(RemotesService, data.remotesService);
setContext(AIPromptService, data.aiPromptService);
setContext(LineManagerFactory, data.lineManagerFactory);
setContext(StackingLineManagerFactory, data.stackingLineManagerFactory);
setNameNormalizationServiceContext(new IpcNameNormalizationService(invoke));
const user = data.userService.user;

View File

@ -12,6 +12,7 @@ import { RemotesService } from '$lib/remotes/service';
import { RustSecretService } from '$lib/secrets/secretsService';
import { UserService } from '$lib/stores/user';
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
import { LineManagerFactory as StackingLineManagerFactory } from '@gitbutler/ui/commitLinesStacking/lineManager';
import lscache from 'lscache';
import type { LayoutLoad } from './$types';
@ -43,6 +44,7 @@ export const load: LayoutLoad = async () => {
const remotesService = new RemotesService();
const aiPromptService = new AIPromptService();
const lineManagerFactory = new LineManagerFactory();
const stackingLineManagerFactory = new StackingLineManagerFactory();
return {
authService,
@ -56,6 +58,7 @@ export const load: LayoutLoad = async () => {
remotesService,
aiPromptService,
lineManagerFactory,
stackingLineManagerFactory,
secretsService
};
};

View File

@ -53,7 +53,7 @@
}
&.remote {
--border-color: var(--clr-commit-upstream);
--border-color: var(--clr-commit-remote);
}
&.local {

View File

@ -0,0 +1,26 @@
<script lang="ts">
import type { CellData } from '$lib/commitLinesStacking/types';
interface Props {
cell: CellData;
isBottom?: boolean;
}
const { cell, isBottom = false }: Props = $props();
</script>
<div
class="commit-line stacked"
class:local={cell.type === 'Local'}
class:remote={cell.type === 'LocalRemote'}
class:local-shadow={cell.type === 'LocalShadow'}
class:upstream={cell.type === 'Upstream'}
class:integrated={cell.type === 'Integrated'}
class:dashed={cell.style === 'dashed' || isBottom}
></div>
<style lang="postcss">
.commit-line {
border-right: 2px var(--border-style) var(--border-color);
}
</style>

View File

@ -0,0 +1,139 @@
<script lang="ts">
import Tooltip from '$lib/Tooltip.svelte';
import { isDefined } from '$lib/utils/typeguards';
import type { CellType, CommitNodeData } from '$lib/commitLinesStacking/types';
interface Props {
commitNode: CommitNodeData;
type: CellType;
}
const { commitNode, type }: Props = $props();
const hoverText = $derived(
[
commitNode.commit?.author?.name,
commitNode.commit?.title,
commitNode.commit?.id.substring(0, 7)
]
.filter(isDefined)
.join('\n')
);
const hoverTextShadow = $derived.by(() => {
return commitNode.type === 'LocalShadow'
? [
commitNode.commit?.relatedRemoteCommit?.author?.name,
commitNode.commit?.relatedRemoteCommit?.title,
commitNode.commit?.relatedRemoteCommit?.id.substring(0, 7)
]
.filter(isDefined)
.join('\n')
: undefined;
});
</script>
<div class="container">
{#if type === 'Local'}
<svg
class="local-commit-dot"
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="10" height="10" rx="5" />
</svg>
{:else if type === 'LocalShadow'}
<div class="local-shadow-commit-dot">
<Tooltip text={hoverTextShadow}>
<svg
class="shadow-dot"
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.827119 6.41372C0.0460709 5.63267 0.0460709 4.36634 0.827119 3.58529L3.70602 0.706392C4.48707 -0.0746567 5.7534 -0.0746567 6.53445 0.706392L9.41335 3.58529C10.1944 4.36634 10.1944 5.63267 9.41335 6.41372L6.53445 9.29262C5.7534 10.0737 4.48707 10.0737 3.70602 9.29262L0.827119 6.41372Z"
/>
</svg>
</Tooltip>
<Tooltip text={hoverText}>
<svg
class="local-dot"
width="11"
height="10"
viewBox="0 0 11 10"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.740712 8.93256C1.59096 9.60118 2.66337 10 3.82893 10H5.82893C8.59035 10 10.8289 7.76142 10.8289 5C10.8289 2.23858 8.59035 0 5.82893 0H3.82893C2.66237 0 1.58912 0.399504 0.738525 1.06916L1.84289 2.17353C3.40499 3.73562 3.40499 6.26828 1.84289 7.83038L0.740712 8.93256Z"
/>
</svg>
</Tooltip>
</div>
{:else}
<Tooltip text={hoverText}>
<svg
class="generic-commit-dot"
class:remote={type === 'LocalRemote'}
class:upstream={type === 'Upstream'}
class:integrated={type === 'Integrated'}
width="11"
height="12"
viewBox="0 0 11 12"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.585786 7.41422C-0.195262 6.63317 -0.195262 5.36684 0.585786 4.58579L3.793 1.37857C4.57405 0.597523 5.84038 0.597524 6.62143 1.37857L9.82865 4.58579C10.6097 5.36684 10.6097 6.63317 9.82865 7.41422L6.62143 10.6214C5.84038 11.4025 4.57405 11.4025 3.793 10.6214L0.585786 7.41422Z"
/>
</svg>
</Tooltip>
{/if}
</div>
<style lang="postcss">
.container {
position: relative;
z-index: var(--z-ground);
}
.local-commit-dot {
transform: translateX(4px);
fill: var(--clr-commit-local);
}
.generic-commit-dot {
transform: translateX(5px);
&.remote {
fill: var(--clr-commit-remote);
}
&.upstream {
fill: var(--clr-commit-upstream);
}
&.integrated {
fill: var(--clr-commit-integrated);
}
}
.local-shadow-commit-dot {
display: flex;
transform: translateX(5px);
.shadow-dot {
fill: var(--clr-commit-shadow);
}
.local-dot {
fill: var(--clr-commit-local);
transform: translateX(-1px);
}
}
</style>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import Cell from '$lib/commitLinesStacking/Cell.svelte';
import CommitNode from '$lib/commitLinesStacking/CommitNode.svelte';
import type { LineData } from '$lib/commitLinesStacking/types';
interface Props {
line: LineData;
isBottom?: boolean;
}
const { line, isBottom = false }: Props = $props();
</script>
<div class="line">
<div class="line-top">
<Cell cell={line.top} />
</div>
{#if line.commitNode}
<CommitNode commitNode={line.commitNode} type={line.commitNode.type ?? 'Local'} />
{/if}
<div class="line-bottom">
<Cell cell={line.bottom} {isBottom} />
</div>
</div>
<style lang="postcss">
.line {
height: 100%;
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: flex-end;
width: 24px;
margin-right: 8px;
}
.line-top {
height: 16px;
width: 100%;
}
.line-bottom {
flex-grow: 1;
width: 100%;
}
</style>

View File

@ -0,0 +1,156 @@
import type { CommitData, LineData, CellType, Style } from '$lib/commitLinesStacking/types';
interface Commits {
remoteCommits: CommitData[];
localCommits: CommitData[];
localAndRemoteCommits: CommitData[];
integratedCommits: CommitData[];
}
// TODO: This can probably be simplified even further as we now only have 1 column (no forks) and we also don't
// split up commit colors, meaning that the top bar, the commit dot, and the bottom bar section are all always
// of the same color / type.
function generateLineData({
remoteCommits,
localCommits,
localAndRemoteCommits,
integratedCommits
}: Commits) {
const remoteBranchGroups = mapToCommitLineGroupPair(remoteCommits);
const localBranchGroups = mapToCommitLineGroupPair(localCommits);
const localAndRemoteBranchGroups = mapToCommitLineGroupPair(localAndRemoteCommits);
const integratedBranchGroups = mapToCommitLineGroupPair(integratedCommits);
remoteBranchGroups.forEach(({ commit, line }) => {
line.top.type = 'LocalRemote';
line.bottom.type = 'LocalRemote';
line.commitNode = { type: 'LocalRemote', commit };
// If there are local commits we want to fill in a local dashed line
if (localBranchGroups.length > 0) {
line.commitNode.type = 'Local';
line.top.type = 'Local';
line.bottom.type = 'Local';
line.top.style = 'dashed';
line.bottom.style = 'dashed';
}
});
let localCommitWithChangeIdFound = false;
localBranchGroups.forEach(({ commit, line }) => {
line.top.type = 'Local';
line.bottom.type = 'Local';
line.commitNode = { type: 'Local', commit };
if (localCommitWithChangeIdFound) {
// If a commit with a change ID has been found above this commit, use the leftStyle
line.top.type = 'LocalShadow';
line.bottom.type = 'LocalShadow';
if (commit.relatedRemoteCommit) {
line.commitNode = {
type: 'LocalShadow',
commit
};
}
} else {
if (commit.relatedRemoteCommit) {
// For the first commit with a change ID found, only set the top if there are any remote commits
if (remoteBranchGroups.length > 0) {
line.top.type = 'LocalShadow';
}
line.commitNode = {
type: 'LocalShadow',
commit
};
line.bottom.type = 'LocalShadow';
localCommitWithChangeIdFound = true;
} else {
// If there are any remote commits, continue the line
if (remoteBranchGroups.length > 0) {
line.top.type = 'LocalRemote';
line.bottom.type = 'LocalRemote';
line.commitNode.type = 'LocalRemote';
}
}
}
});
localAndRemoteBranchGroups.forEach(({ commit, line }) => {
line.top.type = 'LocalRemote';
line.bottom.type = 'LocalRemote';
line.commitNode = { type: 'LocalRemote', commit };
});
integratedBranchGroups.forEach(({ commit, line }) => {
line.top.type = 'Integrated';
line.bottom.type = 'Integrated';
line.commitNode = { type: 'Integrated', commit };
});
const data = new Map<string, LineData>([
...remoteBranchGroups.map(({ commit, line }) => [commit.id, line]),
...localBranchGroups.map(({ commit, line }) => [commit.id, line]),
...localAndRemoteBranchGroups.map(({ commit, line }) => [commit.id, line]),
...integratedBranchGroups.map(({ commit, line }) => [commit.id, line])
] as [string, LineData][]);
// Ensure bottom line is dashed
[...data].reverse().find(([key, value]) => {
if (!key.includes('-spacer')) {
value.bottom.style = 'dashed';
data.set(key, value);
return true;
}
return false;
});
return { data };
}
function mapToCommitLineGroupPair(commits: CommitData[]) {
if (commits.length === 0) return [];
const groupings = commits.map((commit) => ({
commit,
line: {
top: { type: 'Local' as CellType, style: 'solid' as Style },
bottom: { type: 'Local' as CellType, style: 'solid' as Style },
commitNode: { type: 'Local' as CellType, commit }
}
}));
return groupings;
}
/**
* The Line Manager assumes that the groups of commits will be in the following order:
* 1. Remote Commits (Commits you don't have in your branch)
* 2. Local Commits (Commits that you have changed locally)
* 3. LocalAndRemote Commits (Commits that exist locally and on the remote and have the same hash)
* 4. Integrated Commits (Commits that exist locally and perhaps on the remote that are in the trunk)
*/
export class LineManager {
private data: Map<string, LineData>;
constructor(commits: Commits) {
const { data } = generateLineData(commits);
this.data = data;
}
get(commitId: string) {
if (!this.data.has(commitId)) {
throw new Error(`Failed to find commit ${commitId} in line manager`);
}
return this.data.get(commitId)!;
}
}
export class LineManagerFactory {
build(commits: Commits) {
return new LineManager(commits);
}
}

View File

@ -0,0 +1,35 @@
export type Style = 'dashed' | 'solid';
export interface CellData {
type: CellType;
style?: Style;
}
export interface CommitNodeData {
commit: CommitData;
type?: CellType;
}
export interface LineData {
top: CellData;
bottom: CellData;
commitNode?: CommitNodeData;
}
export interface Author {
name?: string;
email?: string;
gravatarUrl?: string;
}
/**
* A minimal set of data required to represent a commit for line drawing purpouses
*/
export interface CommitData {
id: string;
title?: string;
// If an author is not provided, a commit node will not be drawn
author?: Author;
relatedRemoteCommit?: CommitData;
}
export type CellType = 'Local' | 'LocalRemote' | 'Integrated' | 'Upstream' | 'LocalShadow';

View File

@ -3530,6 +3530,25 @@
}
}
}
},
"integrated": {
"$type": "color",
"$value": "{clr-core.purp.50}",
"$description": "",
"$extensions": {
"mode": {
"light": "{clr-core.purp.50}",
"dark": "{clr-core.purp.40}"
},
"figma": {
"variableId": "VariableID:4666:4125",
"collection": {
"id": "VariableCollectionId:8:1868",
"name": "clr",
"defaultModeId": "8:5"
}
}
}
}
},
"text": {
@ -4099,36 +4118,42 @@
"shadow": {
"s": {
"$type": "shadow",
"$value": {
"inset": false,
"color": "#0000000f",
"offsetX": "0px",
"offsetY": "4px",
"blur": "14px",
"spread": "0px"
}
"$value": [
{
"inset": false,
"color": "#0000000f",
"offsetX": "0px",
"offsetY": "4px",
"blur": "14px",
"spread": "0px"
}
]
},
"m": {
"$type": "shadow",
"$value": {
"inset": false,
"color": "#00000014",
"offsetX": "0px",
"offsetY": "6px",
"blur": "30px",
"spread": "0px"
}
"$value": [
{
"inset": false,
"color": "#00000014",
"offsetX": "0px",
"offsetY": "6px",
"blur": "30px",
"spread": "0px"
}
]
},
"l": {
"$type": "shadow",
"$value": {
"inset": false,
"color": "#0000001a",
"offsetX": "0px",
"offsetY": "10px",
"blur": "40px",
"spread": "0px"
}
"$value": [
{
"inset": false,
"color": "#0000001a",
"offsetX": "0px",
"offsetY": "10px",
"blur": "40px",
"spread": "0px"
}
]
}
}
},
@ -4137,7 +4162,7 @@
"useDTCGKeys": true,
"colorMode": "hex",
"variableCollections": ["clr-core", "clr", "size", "radius"],
"createdAt": "2024-09-11T23:46:21.639Z"
"createdAt": "2024-09-24T14:55:11.375Z"
}
}
}

View File

@ -22,6 +22,7 @@
conflicted?: boolean;
locked?: boolean;
lockText?: string;
stacking?: boolean;
oncheck?: (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
@ -49,6 +50,7 @@
conflicted,
locked,
lockText,
stacking = false,
oncheck,
onclick,
onkeydown,
@ -65,6 +67,7 @@
class:selected-draggable={selected}
class:clickable
class:draggable
class:stacking
aria-selected={selected}
role="option"
tabindex="-1"
@ -140,6 +143,15 @@
&:last-child {
border-bottom: none;
}
&.stacking {
background: transparent;
border-bottom: none;
}
&:not(:last-child).stacking {
border-bottom: 1px solid var(--clr-border-3);
}
}
.file-list-item.clickable {
@ -215,8 +227,7 @@
}
/* MODIFIERS */
.selected-draggable {
background-color: var(--clr-theme-pop-bg);
background-color: var(--clr-theme-pop-bg) !important;
}
</style>

View File

@ -0,0 +1,113 @@
import DemoCommitLines from './DemoCommitLines.svelte';
import type { Author, CommitData } from '$lib/commitLinesStacking/types';
import type { Meta, StoryObj } from '@storybook/svelte';
const meta = {
title: 'Commit Lines Stacking/ Variants',
component: DemoCommitLines
} satisfies Meta<DemoCommitLines>;
export default meta;
type Story = StoryObj<typeof meta>;
const caleb: Author = {
email: 'hello@calebowens.com',
gravatarUrl: 'https://gravatar.com/avatar/f43ef760d895a84ca7bb35ff6f4c6b7c'
};
function author() {
return caleb;
}
function commit(): CommitData {
return {
id: crypto.randomUUID(),
title: 'This is a commit',
author: author()
};
}
function relatedCommit(): CommitData {
return {
id: crypto.randomUUID(),
title: 'This is a commit with relations',
author: author(),
relatedRemoteCommit: {
id: crypto.randomUUID(),
title: 'This is a related commit',
author: author()
}
};
}
export const allPopulated: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [commit(), commit(), relatedCommit(), relatedCommit()],
localAndRemoteCommits: [commit(), commit()],
integratedCommits: [commit(), commit()]
}
};
export const noLocals: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [],
localAndRemoteCommits: [commit(), commit()],
integratedCommits: [commit(), commit()]
}
};
export const noLocalAndRemotes: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [commit(), relatedCommit()],
localAndRemoteCommits: [],
integratedCommits: [commit(), commit()]
}
};
export const noLocalAndRemotesOrIntegrateds: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [commit(), relatedCommit()],
localAndRemoteCommits: [],
integratedCommits: []
}
};
export const noRemote: Story = {
args: {
remoteCommits: [],
localCommits: [commit(), relatedCommit()],
localAndRemoteCommits: [commit()],
integratedCommits: [commit(), commit()]
}
};
export const noIntegrated: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [commit(), relatedCommit()],
localAndRemoteCommits: [],
integratedCommits: []
}
};
export const localAndShadowOnly: Story = {
args: {
remoteCommits: [],
localCommits: [relatedCommit(), relatedCommit()],
localAndRemoteCommits: [],
integratedCommits: []
}
};
export const onlyRemote: Story = {
args: {
remoteCommits: [commit(), commit()],
localCommits: [],
localAndRemoteCommits: [],
integratedCommits: []
}
};

View File

@ -0,0 +1,50 @@
<script lang="ts">
import Line from '$lib/commitLinesStacking/Line.svelte';
import { LineManager } from '$lib/commitLinesStacking/lineManager';
import type { CommitData } from '$lib/commitLinesStacking/types';
interface Props {
remoteCommits: CommitData[];
localCommits: CommitData[];
localAndRemoteCommits: CommitData[];
integratedCommits: CommitData[];
}
const { remoteCommits, localCommits, localAndRemoteCommits, integratedCommits }: Props = $props();
const lineManager = $derived(
new LineManager({ remoteCommits, localCommits, localAndRemoteCommits, integratedCommits })
);
</script>
<div class="column">
{#each remoteCommits as commit}
<div class="group">
<Line line={lineManager.get(commit.id)} />
</div>
{/each}
{#each localCommits as commit}
<div class="group">
<Line line={lineManager.get(commit.id)} />
</div>
{/each}
{#each localAndRemoteCommits as commit}
<div class="group">
<Line line={lineManager.get(commit.id)} />
</div>
{/each}
{#each integratedCommits as commit}
<div class="group">
<Line line={lineManager.get(commit.id)} />
</div>
{/each}
</div>
<style lang="postcss">
.group {
height: 68px;
}
</style>

View File

@ -11,7 +11,7 @@
}
&.remote {
--border-color: var(--clr-commit-upstream);
--border-color: var(--clr-commit-remote);
}
&.local {
@ -22,12 +22,20 @@
--border-color: var(--clr-commit-remote);
}
&.local-shadow {
--border-color: var(--clr-commit-local);
}
&.shadow {
--border-color: var(--clr-commit-shadow);
}
&.integrated {
--border-color: var(--clr-commit-shadow);
&.stacked {
--border-color: var(--clr-commit-integrated);
}
}
&.dashed {

View File

@ -122,10 +122,15 @@
}
}
.draggable-commit-author-img {
width: 12px;
height: 12px;
border-radius: 50%;
}
.draggable-commit-info {
display: flex;
align-items: center;
/* gap: 8px; */
}
.draggable-commit-info-text {
@ -134,7 +139,7 @@
text-overflow: ellipsis;
color: var(--clr-text-2);
&:not(:last-child):after {
&:not(:first-child):before {
content: '•';
margin: 0 5px;
color: var(--clr-text-3);

View File

@ -197,6 +197,7 @@
--clr-commit-local: var(--clr-core-pop-50);
--clr-commit-remote: var(--clr-core-ntrl-50);
--clr-commit-shadow: var(--clr-core-ntrl-60);
--clr-commit-integrated: var(--clr-core-purp-50);
--clr-text-1: var(--clr-core-ntrl-5);
--clr-text-2: var(--clr-core-ntrl-50);
--clr-text-3: var(--clr-core-ntrl-60);
@ -375,6 +376,7 @@
--clr-commit-local: var(--clr-core-pop-50);
--clr-commit-remote: var(--clr-core-ntrl-50);
--clr-commit-shadow: var(--clr-core-ntrl-40);
--clr-commit-integrated: var(--clr-core-purp-40);
--clr-text-1: var(--clr-core-ntrl-95);
--clr-text-2: var(--clr-core-ntrl-60);
--clr-text-3: var(--clr-core-ntrl-40);