mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-23 20:54:50 +03:00
feat: new stacking ui commit lines (#4972)
Co-authored-by: Pavel Laptev <pawellaptew@gmail.com>
This commit is contained in:
parent
8b84f46df1
commit
d10bbcf515
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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}>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
647
apps/desktop/src/lib/commit/StackingCommitCard.svelte
Normal file
647
apps/desktop/src/lib/commit/StackingCommitCard.svelte
Normal 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>
|
59
apps/desktop/src/lib/commit/StackingCommitDragItem.svelte
Normal file
59
apps/desktop/src/lib/commit/StackingCommitDragItem.svelte
Normal 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>
|
303
apps/desktop/src/lib/commit/StackingCommitList.svelte
Normal file
303
apps/desktop/src/lib/commit/StackingCommitList.svelte
Normal 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>
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
&.remote {
|
||||
--border-color: var(--clr-commit-upstream);
|
||||
--border-color: var(--clr-commit-remote);
|
||||
}
|
||||
|
||||
&.local {
|
||||
|
26
packages/ui/src/lib/commitLinesStacking/Cell.svelte
Normal file
26
packages/ui/src/lib/commitLinesStacking/Cell.svelte
Normal 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>
|
139
packages/ui/src/lib/commitLinesStacking/CommitNode.svelte
Normal file
139
packages/ui/src/lib/commitLinesStacking/CommitNode.svelte
Normal 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>
|
46
packages/ui/src/lib/commitLinesStacking/Line.svelte
Normal file
46
packages/ui/src/lib/commitLinesStacking/Line.svelte
Normal 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>
|
156
packages/ui/src/lib/commitLinesStacking/lineManager.ts
Normal file
156
packages/ui/src/lib/commitLinesStacking/lineManager.ts
Normal 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);
|
||||
}
|
||||
}
|
35
packages/ui/src/lib/commitLinesStacking/types.ts
Normal file
35
packages/ui/src/lib/commitLinesStacking/types.ts
Normal 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';
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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: []
|
||||
}
|
||||
};
|
@ -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>
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user