mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-25 18:49:11 +03:00
Merge pull request #4109 from gitbutlerapp/update-modal-component-branch
Update modal component branch
This commit is contained in:
commit
ffaffe6cde
53
app/src/lib/branches/dragActions.ts
Normal file
53
app/src/lib/branches/dragActions.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { DraggableCommit, DraggableHunk, DraggableFile } from '$lib/dragging/draggables';
|
||||
import { filesToOwnership } from '$lib/vbranches/ownership';
|
||||
import type { BranchController } from '$lib/vbranches/branchController';
|
||||
import type { Branch } from '$lib/vbranches/types';
|
||||
|
||||
class BranchDragActions {
|
||||
constructor(
|
||||
private branchController: BranchController,
|
||||
private branch: Branch
|
||||
) {}
|
||||
|
||||
acceptMoveCommit(data: any) {
|
||||
return data instanceof DraggableCommit && data.branchId !== this.branch.id && data.isHeadCommit;
|
||||
}
|
||||
|
||||
onMoveCommit(data: DraggableCommit) {
|
||||
this.branchController.moveCommit(this.branch.id, data.commit.id);
|
||||
}
|
||||
|
||||
acceptBranchDrop(data: any) {
|
||||
if (data instanceof DraggableHunk && data.branchId !== this.branch.id) {
|
||||
return !data.hunk.locked;
|
||||
} else if (data instanceof DraggableFile && data.branchId && data.branchId !== this.branch.id) {
|
||||
return !data.files.some((f) => f.locked);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onBranchDrop(data: DraggableHunk | DraggableFile) {
|
||||
if (data instanceof DraggableHunk) {
|
||||
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
|
||||
this.branchController.updateBranchOwnership(
|
||||
this.branch.id,
|
||||
(newOwnership + '\n' + this.branch.ownership).trim()
|
||||
);
|
||||
} else if (data instanceof DraggableFile) {
|
||||
const newOwnership = filesToOwnership(data.files);
|
||||
this.branchController.updateBranchOwnership(
|
||||
this.branch.id,
|
||||
(newOwnership + '\n' + this.branch.ownership).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BranchDragActionsFactory {
|
||||
constructor(private branchController: BranchController) {}
|
||||
|
||||
build(branch: Branch) {
|
||||
return new BranchDragActions(this.branchController, branch);
|
||||
}
|
||||
}
|
108
app/src/lib/commits/dragActions.ts
Normal file
108
app/src/lib/commits/dragActions.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { DraggableCommit, DraggableFile, DraggableHunk } from '$lib/dragging/draggables';
|
||||
import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership';
|
||||
import {
|
||||
LocalFile,
|
||||
RemoteCommit,
|
||||
RemoteFile,
|
||||
type Branch,
|
||||
type Commit
|
||||
} from '$lib/vbranches/types';
|
||||
import type { Project } from '$lib/backend/projects';
|
||||
import type { BranchController } from '$lib/vbranches/branchController';
|
||||
|
||||
class CommitDragActions {
|
||||
constructor(
|
||||
private branchController: BranchController,
|
||||
private project: Project,
|
||||
private branch: Branch,
|
||||
private commit: Commit | RemoteCommit
|
||||
) {}
|
||||
|
||||
acceptAmend(data: any) {
|
||||
if (this.commit instanceof RemoteCommit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.project.ok_with_force_push && this.commit.isRemote) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.commit.isIntegrated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data instanceof DraggableHunk && data.branchId === this.branch.id) {
|
||||
return true;
|
||||
} else if (data instanceof DraggableFile && data.branchId === this.branch.id) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onAmend(data: any) {
|
||||
if (data instanceof DraggableHunk) {
|
||||
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
|
||||
this.branchController.amendBranch(this.branch.id, this.commit.id, newOwnership);
|
||||
} else if (data instanceof DraggableFile) {
|
||||
if (data.file instanceof LocalFile) {
|
||||
// this is an uncommitted file change being amended to a previous commit
|
||||
const newOwnership = filesToOwnership(data.files);
|
||||
this.branchController.amendBranch(this.branch.id, this.commit.id, newOwnership);
|
||||
} else if (data.file instanceof RemoteFile) {
|
||||
// this is a file from a commit, rather than an uncommitted file
|
||||
const newOwnership = filesToSimpleOwnership(data.files);
|
||||
if (data.commit) {
|
||||
this.branchController.moveCommitFile(
|
||||
this.branch.id,
|
||||
data.commit.id,
|
||||
this.commit.id,
|
||||
newOwnership
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acceptSquash(data: any) {
|
||||
if (this.commit instanceof RemoteCommit) {
|
||||
return false;
|
||||
}
|
||||
if (!(data instanceof DraggableCommit)) return false;
|
||||
if (data.branchId !== this.branch.id) return false;
|
||||
|
||||
if (data.commit.isParentOf(this.commit)) {
|
||||
if (data.commit.isIntegrated) return false;
|
||||
if (data.commit.isRemote && !this.project.ok_with_force_push) return false;
|
||||
return true;
|
||||
} else if (this.commit.isParentOf(data.commit)) {
|
||||
if (this.commit.isIntegrated) return false;
|
||||
if (this.commit.isRemote && !this.project.ok_with_force_push) return false;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onSquash(data: any) {
|
||||
if (this.commit instanceof RemoteCommit) {
|
||||
return;
|
||||
}
|
||||
if (data.commit.isParentOf(this.commit)) {
|
||||
this.branchController.squashBranchCommit(data.branchId, this.commit.id);
|
||||
} else if (this.commit.isParentOf(data.commit)) {
|
||||
this.branchController.squashBranchCommit(data.branchId, data.commit.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CommitDragActionsFactory {
|
||||
constructor(
|
||||
private branchController: BranchController,
|
||||
private project: Project
|
||||
) {}
|
||||
|
||||
build(branch: Branch, commit: Commit | RemoteCommit) {
|
||||
return new CommitDragActions(this.branchController, this.project, branch, commit);
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts" async="true">
|
||||
import FullviewLoading from './FullviewLoading.svelte';
|
||||
import NewBranchDropZone from './NewBranchDropZone.svelte';
|
||||
import dzenSvg from '$lib/assets/dzen-pc.svg?raw';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import BranchDropzone from '$lib/components/BranchDropzone.svelte';
|
||||
import BranchLane from '$lib/components/BranchLane.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { cloneWithRotation } from '$lib/dragging/draggable';
|
||||
@ -193,7 +193,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<NewBranchDropZone />
|
||||
<BranchDropzone />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -4,7 +4,6 @@
|
||||
import BranchHeader from './BranchHeader.svelte';
|
||||
import CommitDialog from './CommitDialog.svelte';
|
||||
import CommitList from './CommitList.svelte';
|
||||
import DropzoneOverlay from './DropzoneOverlay.svelte';
|
||||
import EmptyStatePlaceholder from './EmptyStatePlaceholder.svelte';
|
||||
import InfoMessage from './InfoMessage.svelte';
|
||||
import PullRequestCard from './PullRequestCard.svelte';
|
||||
@ -14,16 +13,10 @@
|
||||
import laneNewSvg from '$lib/assets/empty-state/lane-new.svg?raw';
|
||||
import noChangesSvg from '$lib/assets/empty-state/lane-no-changes.svg?raw';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import BranchCardDropzones from '$lib/components/BranchCard/Dropzones.svelte';
|
||||
import Resizer from '$lib/components/Resizer.svelte';
|
||||
import { projectAiGenAutoBranchNamingEnabled } from '$lib/config/config';
|
||||
import { projectAiGenEnabled } from '$lib/config/config';
|
||||
import {
|
||||
DraggableCommit,
|
||||
DraggableFile,
|
||||
DraggableHunk,
|
||||
DraggableRemoteCommit
|
||||
} from '$lib/dragging/draggables';
|
||||
import { dropzone } from '$lib/dragging/dropzone';
|
||||
import { showError } from '$lib/notifications/toasts';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
|
||||
@ -32,7 +25,6 @@
|
||||
import { computeAddedRemovedByFiles } from '$lib/utils/metrics';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { FileIdSelection } from '$lib/vbranches/fileIdSelection';
|
||||
import { filesToOwnership } from '$lib/vbranches/ownership';
|
||||
import { Branch } from '$lib/vbranches/types';
|
||||
import lscache from 'lscache';
|
||||
import { onMount } from 'svelte';
|
||||
@ -106,47 +98,6 @@
|
||||
onMount(() => {
|
||||
laneWidth = lscache.get(laneWidthKey + branch.id);
|
||||
});
|
||||
|
||||
function acceptMoveCommit(data: any) {
|
||||
return data instanceof DraggableCommit && data.branchId !== branch.id && data.isHeadCommit;
|
||||
}
|
||||
function onCommitDrop(data: DraggableCommit) {
|
||||
branchController.moveCommit(branch.id, data.commit.id);
|
||||
}
|
||||
|
||||
function acceptCherrypick(data: any) {
|
||||
return data instanceof DraggableRemoteCommit && data.branchId === branch.id;
|
||||
}
|
||||
|
||||
function onCherrypicked(data: DraggableRemoteCommit) {
|
||||
branchController.cherryPick(branch.id, data.remoteCommit.id);
|
||||
}
|
||||
|
||||
function acceptBranchDrop(data: any) {
|
||||
if (data instanceof DraggableHunk && data.branchId !== branch.id) {
|
||||
return !data.hunk.locked;
|
||||
} else if (data instanceof DraggableFile && data.branchId && data.branchId !== branch.id) {
|
||||
return !data.files.some((f) => f.locked);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function onBranchDrop(data: DraggableHunk | DraggableFile) {
|
||||
if (data instanceof DraggableHunk) {
|
||||
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
|
||||
branchController.updateBranchOwnership(
|
||||
branch.id,
|
||||
(newOwnership + '\n' + branch.ownership).trim()
|
||||
);
|
||||
} else if (data instanceof DraggableFile) {
|
||||
const newOwnership = filesToOwnership(data.files);
|
||||
branchController.updateBranchOwnership(
|
||||
branch.id,
|
||||
(newOwnership + '\n' + branch.ownership).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLaneCollapsed}
|
||||
@ -194,40 +145,9 @@
|
||||
}}
|
||||
/>
|
||||
<PullRequestCard />
|
||||
<!-- DROPZONES -->
|
||||
<DropzoneOverlay class="cherrypick-dz-marker" label="Apply here" />
|
||||
<DropzoneOverlay class="cherrypick-dz-marker" label="Apply here" />
|
||||
<DropzoneOverlay class="lane-dz-marker" label="Move here" />
|
||||
|
||||
<div class="card">
|
||||
<div
|
||||
class="branch-card__dropzone-wrapper"
|
||||
use:dropzone={{
|
||||
hover: 'move-commit-dz-hover',
|
||||
active: 'move-commit-dz-active',
|
||||
accepts: acceptMoveCommit,
|
||||
onDrop: onCommitDrop,
|
||||
disabled: isUnapplied
|
||||
}}
|
||||
use:dropzone={{
|
||||
hover: 'cherrypick-dz-hover',
|
||||
active: 'cherrypick-dz-active',
|
||||
accepts: acceptCherrypick,
|
||||
onDrop: onCherrypicked,
|
||||
disabled: isUnapplied
|
||||
}}
|
||||
use:dropzone={{
|
||||
hover: 'lane-dz-hover',
|
||||
active: 'lane-dz-active',
|
||||
accepts: acceptBranchDrop,
|
||||
onDrop: onBranchDrop,
|
||||
disabled: isUnapplied
|
||||
}}
|
||||
>
|
||||
<DropzoneOverlay class="cherrypick-dz-marker" label="Apply here" />
|
||||
<DropzoneOverlay class="lane-dz-marker" label="Move here" />
|
||||
<DropzoneOverlay class="move-commit-dz-marker" label="Move here" />
|
||||
|
||||
<BranchCardDropzones>
|
||||
{#if branch.files?.length > 0}
|
||||
<div class="branch-card__files">
|
||||
<BranchFiles
|
||||
@ -282,7 +202,7 @@
|
||||
</EmptyStatePlaceholder>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</BranchCardDropzones>
|
||||
|
||||
<div class="card-commits">
|
||||
<CommitList {isUnapplied} />
|
||||
@ -331,13 +251,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.branch-card__dropzone-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.branch-card__contents {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@ -358,6 +271,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
/* border-left: 1px solid var(--clr-border-2);
|
||||
border-right: 1px solid var(--clr-border-2);
|
||||
border-radius: var(--radius-m) var(--radius-m) 0 0; */
|
||||
@ -373,7 +287,7 @@
|
||||
.no-changes {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--clr-scale-ntrl-60);
|
||||
@ -383,19 +297,6 @@
|
||||
cursor: default; /* was defaulting to text cursor */
|
||||
}
|
||||
|
||||
/* hunks drop zone */
|
||||
/* cherry pick drop zone */
|
||||
/* move commit drop zone */
|
||||
/* squash drop zone */
|
||||
:global(
|
||||
.lane-dz-active .lane-dz-marker,
|
||||
.cherrypick-dz-active .cherrypick-dz-marker,
|
||||
.move-commit-dz-active .move-commit-dz-marker,
|
||||
.squash-dz-active .squash-dz-marker
|
||||
) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.branch-card :global(.contents) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
72
app/src/lib/components/BranchCard/Dropzones.svelte
Normal file
72
app/src/lib/components/BranchCard/Dropzones.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { BranchDragActionsFactory } from '$lib/branches/dragActions';
|
||||
import CardOverlay from '$lib/components/Dropzone/CardOverlay.svelte';
|
||||
import Dropzone from '$lib/components/Dropzone/Dropzone.svelte';
|
||||
import { getContext, getContextStore } from '$lib/utils/context';
|
||||
import { Branch } from '$lib/vbranches/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
const branchDragActionsFactory = getContext(BranchDragActionsFactory);
|
||||
const branch = getContextStore(Branch);
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
const actions = $derived(branchDragActionsFactory.build($branch));
|
||||
</script>
|
||||
|
||||
<div class="commit-list-item">
|
||||
<div class="commit-card-wrapper">
|
||||
{@render moveCommitDropzone()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- We require the dropzones to be nested -->
|
||||
{#snippet moveCommitDropzone()}
|
||||
<Dropzone
|
||||
accepts={actions.acceptMoveCommit.bind(actions)}
|
||||
ondrop={actions.onMoveCommit.bind(actions)}
|
||||
fillHeight
|
||||
>
|
||||
{@render branchDropDropzone()}
|
||||
|
||||
{#snippet overlay({ hovered, activated })}
|
||||
<CardOverlay {hovered} {activated} label="Move here" />
|
||||
{/snippet}
|
||||
</Dropzone>
|
||||
{/snippet}
|
||||
|
||||
{#snippet branchDropDropzone()}
|
||||
<Dropzone
|
||||
accepts={actions.acceptBranchDrop.bind(actions)}
|
||||
ondrop={actions.onBranchDrop.bind(actions)}
|
||||
fillHeight
|
||||
>
|
||||
{@render children()}
|
||||
|
||||
{#snippet overlay({ hovered, activated })}
|
||||
<CardOverlay {hovered} {activated} label="Move here" />
|
||||
{/snippet}
|
||||
</Dropzone>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.commit-list-item {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
.commit-card-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
@ -6,8 +6,8 @@
|
||||
import topSheetSvg from '$lib/assets/new-branch/top-sheet.svg?raw';
|
||||
// import components
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Dropzone from '$lib/components/Dropzone/Dropzone.svelte';
|
||||
import { DraggableFile, DraggableHunk } from '$lib/dragging/draggables';
|
||||
import { dropzone } from '$lib/dragging/dropzone';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { filesToOwnership } from '$lib/vbranches/ownership';
|
||||
@ -31,16 +31,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="canvas-dropzone"
|
||||
use:dropzone={{
|
||||
active: 'new-dz-active',
|
||||
hover: 'new-dz-hover',
|
||||
onDrop,
|
||||
accepts
|
||||
}}
|
||||
>
|
||||
<div id="new-branch-dz" class="new-virtual-branch">
|
||||
<div class="canvas-dropzone">
|
||||
<Dropzone {accepts} ondrop={onDrop}>
|
||||
{#snippet overlay({ hovered, activated })}
|
||||
<div class="new-virtual-branch" class:activated class:hovered>
|
||||
<div class="new-virtual-branch__content">
|
||||
<div class="stimg">
|
||||
<div class="stimg__hand">
|
||||
@ -74,6 +68,8 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dropzone>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
@ -90,6 +86,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 352px;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-m);
|
||||
border: 1px dashed var(--clr-border-2);
|
||||
background-color: transparent;
|
||||
@ -214,7 +211,7 @@
|
||||
}
|
||||
|
||||
/* DRAGZONE MODIEFIERS */
|
||||
:global(.canvas-dropzone.new-dz-active) {
|
||||
.activated {
|
||||
&.new-virtual-branch {
|
||||
background-color: oklch(from var(--clr-scale-pop-70) l c h / 0.1);
|
||||
border: 1px dashed oklch(from var(--clr-scale-pop-40) l c h / 0.8);
|
@ -159,7 +159,7 @@
|
||||
kind="solid"
|
||||
on:click={async () => {
|
||||
await branchController.deleteBranch(branch.id);
|
||||
deleteBranchModal.close();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
|
@ -1,130 +1,59 @@
|
||||
<script lang="ts">
|
||||
import DropzoneOverlay from './DropzoneOverlay.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { DraggableCommit, DraggableFile, DraggableHunk } from '$lib/dragging/draggables';
|
||||
import { dropzone } from '$lib/dragging/dropzone';
|
||||
import { CommitDragActionsFactory } from '$lib/commits/dragActions';
|
||||
import CardOverlay from '$lib/components/Dropzone/CardOverlay.svelte';
|
||||
import Dropzone from '$lib/components/Dropzone/Dropzone.svelte';
|
||||
import { getContext, maybeGetContextStore } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { filesToOwnership, filesToSimpleOwnership } from '$lib/vbranches/ownership';
|
||||
import { RemoteCommit, Branch, Commit, LocalFile, RemoteFile } from '$lib/vbranches/types';
|
||||
import { RemoteCommit, Branch, Commit } from '$lib/vbranches/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let commit: Commit | RemoteCommit;
|
||||
const commitDragActionsFactory = getContext(CommitDragActionsFactory);
|
||||
|
||||
interface Props {
|
||||
commit: Commit | RemoteCommit;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { commit, children }: Props = $props();
|
||||
|
||||
const branchController = getContext(BranchController);
|
||||
const project = getContext(Project);
|
||||
const branch = maybeGetContextStore(Branch);
|
||||
|
||||
function acceptAmend(commit: Commit | RemoteCommit) {
|
||||
const actions = $derived.by(() => {
|
||||
if (!$branch) return;
|
||||
|
||||
if (commit instanceof RemoteCommit) {
|
||||
return () => false;
|
||||
}
|
||||
return (data: any) => {
|
||||
if (!project.ok_with_force_push && commit.isRemote) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (commit.isIntegrated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data instanceof DraggableHunk && data.branchId === $branch.id) {
|
||||
return true;
|
||||
} else if (data instanceof DraggableFile && data.branchId === $branch.id) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onAmend(commit: Commit | RemoteCommit) {
|
||||
if (!$branch) return;
|
||||
|
||||
return (data: any) => {
|
||||
if (data instanceof DraggableHunk) {
|
||||
const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`;
|
||||
branchController.amendBranch($branch.id, commit.id, newOwnership);
|
||||
} else if (data instanceof DraggableFile) {
|
||||
if (data.file instanceof LocalFile) {
|
||||
// this is an uncommitted file change being amended to a previous commit
|
||||
const newOwnership = filesToOwnership(data.files);
|
||||
branchController.amendBranch($branch.id, commit.id, newOwnership);
|
||||
} else if (data.file instanceof RemoteFile) {
|
||||
// this is a file from a commit, rather than an uncommitted file
|
||||
const newOwnership = filesToSimpleOwnership(data.files);
|
||||
if (data.commit) {
|
||||
branchController.moveCommitFile($branch.id, data.commit.id, commit.id, newOwnership);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function acceptSquash(commit: Commit | RemoteCommit) {
|
||||
if (!$branch) return;
|
||||
|
||||
if (commit instanceof RemoteCommit) {
|
||||
return () => false;
|
||||
}
|
||||
return (data: any) => {
|
||||
if (!(data instanceof DraggableCommit)) return false;
|
||||
if (data.branchId !== $branch.id) return false;
|
||||
|
||||
if (data.commit.isParentOf(commit)) {
|
||||
if (data.commit.isIntegrated) return false;
|
||||
if (data.commit.isRemote && !project.ok_with_force_push) return false;
|
||||
return true;
|
||||
} else if (commit.isParentOf(data.commit)) {
|
||||
if (commit.isIntegrated) return false;
|
||||
if (commit.isRemote && !project.ok_with_force_push) return false;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onSquash(commit: Commit | RemoteCommit) {
|
||||
if (!$branch) return;
|
||||
|
||||
if (commit instanceof RemoteCommit) {
|
||||
return () => false;
|
||||
}
|
||||
return (data: DraggableCommit) => {
|
||||
if (data.commit.isParentOf(commit)) {
|
||||
branchController.squashBranchCommit(data.branchId, commit.id);
|
||||
} else if (commit.isParentOf(data.commit)) {
|
||||
branchController.squashBranchCommit(data.branchId, data.commit.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
return commitDragActionsFactory.build($branch, commit);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="commit-list-item">
|
||||
<div
|
||||
class="commit-card-wrapper"
|
||||
use:dropzone={{
|
||||
active: 'amend-dz-active',
|
||||
hover: 'amend-dz-hover',
|
||||
accepts: acceptAmend(commit),
|
||||
onDrop: onAmend(commit)
|
||||
}}
|
||||
use:dropzone={{
|
||||
active: 'squash-dz-active',
|
||||
hover: 'squash-dz-hover',
|
||||
accepts: acceptSquash(commit),
|
||||
onDrop: onSquash(commit)
|
||||
}}
|
||||
>
|
||||
<!-- DROPZONES -->
|
||||
<DropzoneOverlay class="amend-dz-marker" label="Amend" />
|
||||
<DropzoneOverlay class="squash-dz-marker" label="Squash" />
|
||||
<div class="commit-card-wrapper">
|
||||
{#if actions}
|
||||
{@render ammendDropzone()}
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</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="Ammend 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>
|
||||
.commit-list-item {
|
||||
|
@ -2,9 +2,13 @@
|
||||
import CommitCard from './CommitCard.svelte';
|
||||
import CommitLines from './CommitLines.svelte';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import ReorderDropzone from '$lib/components/CommitList/ReorderDropzone.svelte';
|
||||
import Dropzone from '$lib/components/Dropzone/Dropzone.svelte';
|
||||
import LineOverlay from '$lib/components/Dropzone/LineOverlay.svelte';
|
||||
import InsertEmptyCommitAction from '$lib/components/InsertEmptyCommitAction.svelte';
|
||||
import { ReorderDropzoneIndexer } from '$lib/dragging/reorderDropzoneIndexer';
|
||||
import {
|
||||
ReorderDropzoneManagerFactory,
|
||||
type ReorderDropzone
|
||||
} from '$lib/dragging/reorderDropzoneManager';
|
||||
import { getAvatarTooltip } from '$lib/utils/avatar';
|
||||
import { getContext } from '$lib/utils/context';
|
||||
import { getContextStore } from '$lib/utils/context';
|
||||
@ -29,6 +33,8 @@
|
||||
const project = getContext(Project);
|
||||
const branchController = getContext(BranchController);
|
||||
|
||||
const reorderDropzoneManagerFactory = getContext(ReorderDropzoneManagerFactory);
|
||||
|
||||
// Force the "base" commit lines to update when $branch updates.
|
||||
let tsKey: number | undefined;
|
||||
$: {
|
||||
@ -44,7 +50,10 @@
|
||||
$: hasIntegratedCommits = $integratedCommits.length > 0;
|
||||
$: hasRemoteCommits = $remoteCommits.length > 0;
|
||||
$: hasShadowedCommits = $localCommits.some((c) => c.relatedTo);
|
||||
$: reorderDropzoneIndexer = new ReorderDropzoneIndexer([...$localCommits, ...$remoteCommits]);
|
||||
$: reorderDropzoneManager = reorderDropzoneManagerFactory.build($branch, [
|
||||
...$localCommits,
|
||||
...$remoteCommits
|
||||
]);
|
||||
|
||||
$: forkPoint = $branch.forkPoint;
|
||||
$: upstreamForkPoint = $branch.upstreamData?.forkPoint;
|
||||
@ -97,8 +106,31 @@
|
||||
}
|
||||
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 || hasUnknownCommits}
|
||||
<div class="commits">
|
||||
<!-- UPSTREAM COMMITS -->
|
||||
@ -138,10 +170,10 @@
|
||||
<InsertEmptyCommitAction isFirst on:click={() => insertBlankCommit($branch.head, 'above')} />
|
||||
<!-- LOCAL COMMITS -->
|
||||
{#if $localCommits.length > 0}
|
||||
<ReorderDropzone
|
||||
index={reorderDropzoneIndexer.topDropzoneIndex}
|
||||
indexer={reorderDropzoneIndexer}
|
||||
/>
|
||||
{@render reorderDropzone(
|
||||
reorderDropzoneManager.topDropzone,
|
||||
getReorderDropzoneOffset({ isFirst: true })
|
||||
)}
|
||||
{#each $localCommits as commit, idx (commit.id)}
|
||||
<CommitCard
|
||||
{commit}
|
||||
@ -176,10 +208,14 @@
|
||||
</svelte:fragment>
|
||||
</CommitCard>
|
||||
|
||||
<ReorderDropzone
|
||||
index={reorderDropzoneIndexer.dropzoneIndexBelowCommit(commit.id)}
|
||||
indexer={reorderDropzoneIndexer}
|
||||
/>
|
||||
{@render reorderDropzone(
|
||||
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
|
||||
getReorderDropzoneOffset({
|
||||
isLast: $remoteCommits.length === 0 && idx + 1 === $localCommits.length,
|
||||
isMiddle: $remoteCommits.length > 0 && idx + 1 === $localCommits.length
|
||||
})
|
||||
)}
|
||||
|
||||
<InsertEmptyCommitAction
|
||||
isLast={$remoteCommits.length === 0 && idx + 1 === $localCommits.length}
|
||||
isMiddle={$remoteCommits.length > 0 && idx + 1 === $localCommits.length}
|
||||
@ -219,10 +255,12 @@
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</CommitCard>
|
||||
<ReorderDropzone
|
||||
index={reorderDropzoneIndexer.dropzoneIndexBelowCommit(commit.id)}
|
||||
indexer={reorderDropzoneIndexer}
|
||||
/>
|
||||
{@render reorderDropzone(
|
||||
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
|
||||
getReorderDropzoneOffset({
|
||||
isLast: idx + 1 === $remoteCommits.length
|
||||
})
|
||||
)}
|
||||
<InsertEmptyCommitAction
|
||||
isLast={idx + 1 === $remoteCommits.length}
|
||||
on:click={() => insertBlankCommit(commit.id, 'below')}
|
||||
|
@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DropzoneOverlay from '$lib/components/DropzoneOverlay.svelte';
|
||||
import { DraggableCommit } from '$lib/dragging/draggables';
|
||||
import { dropzone } from '$lib/dragging/dropzone';
|
||||
import { getContext, getContextStore } from '$lib/utils/context';
|
||||
import { BranchController } from '$lib/vbranches/branchController';
|
||||
import { Branch } from '$lib/vbranches/types';
|
||||
import type { ReorderDropzoneIndexer } from '$lib/dragging/reorderDropzoneIndexer';
|
||||
|
||||
export let index: number;
|
||||
export let indexer: ReorderDropzoneIndexer;
|
||||
|
||||
const branchController = getContext(BranchController);
|
||||
const branch = getContextStore(Branch);
|
||||
|
||||
function accepts(data: any) {
|
||||
if (!(data instanceof DraggableCommit)) return false;
|
||||
if (data.branchId !== $branch.id) return false;
|
||||
if (indexer.dropzoneCommitOffset(index, data.commit.id) === 0) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function onDrop(data: any) {
|
||||
if (!(data instanceof DraggableCommit)) return;
|
||||
if (data.branchId !== $branch.id) return;
|
||||
|
||||
const offset = indexer.dropzoneCommitOffset(index, data.commit.id);
|
||||
branchController.reorderCommit($branch.id, data.commit.id, offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dropzone"
|
||||
use:dropzone={{ accepts, onDrop, active: 'reorder-dz-active', hover: 'reorder-dz-hover' }}
|
||||
>
|
||||
<DropzoneOverlay class="reorder-dz-marker" label="Reorder" />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:root {
|
||||
/* There is something that is increasing the width by 26 pixels and I'm not quite sure what it is */
|
||||
--dropzone-width: calc(100% - 26px);
|
||||
}
|
||||
|
||||
:global(.reorder-dz-active .reorder-dz-marker) {
|
||||
display: flex !important;
|
||||
height: 48px;
|
||||
width: var(--dropzone-width);
|
||||
}
|
||||
|
||||
:global(.reorder-dz-active) {
|
||||
height: 48px;
|
||||
width: var(--dropzone-width);
|
||||
}
|
||||
</style>
|
68
app/src/lib/components/Dropzone/CardOverlay.svelte
Normal file
68
app/src/lib/components/Dropzone/CardOverlay.svelte
Normal file
@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
hovered: boolean;
|
||||
activated: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const { hovered, activated, label = 'Drop here' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="dropzone-target container" class:activated class:hovered>
|
||||
<div class="dropzone-content">
|
||||
<Icon name="new-file-small-filled" />
|
||||
<p class="text-base-13">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:root {
|
||||
--dropzone-height: 16px;
|
||||
--dropzone-overlap: calc(var(--dropzone-height) / 2);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background-color: oklch(from var(--clr-scale-pop-70) l c h / 0.1);
|
||||
|
||||
outline-color: var(--clr-scale-pop-40);
|
||||
outline-style: dashed;
|
||||
outline-width: 1px;
|
||||
outline-offset: -10px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
z-index: var(--z-lifted);
|
||||
|
||||
transition: background-color 0.1s;
|
||||
|
||||
/* It is very important that all children are pointer-events: none */
|
||||
/* https://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element */
|
||||
& * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:not(.activated) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.hovered {
|
||||
background-color: oklch(from var(--clr-scale-pop-20) l c h / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--clr-scale-pop-40);
|
||||
}
|
||||
</style>
|
68
app/src/lib/components/Dropzone/Dropzone.svelte
Normal file
68
app/src/lib/components/Dropzone/Dropzone.svelte
Normal file
@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { dropzone } from '$lib/dragging/dropzone';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
fillHeight?: boolean;
|
||||
accepts: (data: any) => boolean;
|
||||
ondrop: (data: any) => Promise<void> | void;
|
||||
overlay: Snippet<[{ hovered: boolean; activated: boolean }]>;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
const {
|
||||
disabled = false,
|
||||
fillHeight = false,
|
||||
accepts,
|
||||
ondrop,
|
||||
overlay,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let hovered = $state(false);
|
||||
// When a draggable is being hovered over the dropzone
|
||||
function onHoverStart() {
|
||||
hovered = true;
|
||||
}
|
||||
|
||||
function onHoverEnd() {
|
||||
hovered = false;
|
||||
}
|
||||
|
||||
let activated = $state(false);
|
||||
// Fired when a draggable is first picked up and the dropzone accepts the draggable
|
||||
function onActivationStart() {
|
||||
activated = true;
|
||||
}
|
||||
|
||||
function onActivationEnd() {
|
||||
activated = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:dropzone={{
|
||||
disabled,
|
||||
accepts,
|
||||
onDrop: ondrop,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onActivationStart,
|
||||
onActivationEnd,
|
||||
target: '.dropzone-target'
|
||||
}}
|
||||
class:fill-height={fillHeight}
|
||||
>
|
||||
{@render overlay({ hovered, activated })}
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.fill-height {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
62
app/src/lib/components/Dropzone/LineOverlay.svelte
Normal file
62
app/src/lib/components/Dropzone/LineOverlay.svelte
Normal file
@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { pxToRem } from '$lib/utils/pxToRem';
|
||||
|
||||
interface Props {
|
||||
hovered: boolean;
|
||||
activated: boolean;
|
||||
yOffsetPx?: number;
|
||||
}
|
||||
|
||||
const { hovered, activated, yOffsetPx = 0 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dropzone-target container"
|
||||
class:activated
|
||||
style="--y-offset: {pxToRem(yOffsetPx) || 0}"
|
||||
>
|
||||
<div class="indicator" class:hovered></div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:root {
|
||||
--dropzone-height: 16px;
|
||||
--dropzone-overlap: calc(var(--dropzone-height) / 2);
|
||||
}
|
||||
|
||||
.container {
|
||||
height: var(--dropzone-height);
|
||||
margin-top: calc(var(--dropzone-overlap) * -1);
|
||||
margin-bottom: calc(var(--dropzone-overlap) * -1);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top: var(--y-offset);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
z-index: 101;
|
||||
|
||||
/* It is very important that all children are pointer-events: none */
|
||||
/* https://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element */
|
||||
& * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:not(.activated) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
transition: opacity 0.1s;
|
||||
background-color: var(--clr-border-2);
|
||||
opacity: 0;
|
||||
|
||||
&.hovered {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,74 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
export let label = 'Drop here';
|
||||
export let radius: 's' | 'm' | 'l' | undefined = 'm';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{className} dropzone"
|
||||
class:small={radius === 's'}
|
||||
class:medium={radius === 'm'}
|
||||
class:large={radius === 'l'}
|
||||
>
|
||||
<div class="dropzone-wrapper">
|
||||
<div class="dropzone-content">
|
||||
<Icon name="new-file-small-filled" />
|
||||
<span class="text-base-13">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
@layer dropzone {
|
||||
.dropzone {
|
||||
user-select: none;
|
||||
display: none;
|
||||
z-index: var(--z-lifted);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background-color: oklch(from var(--clr-scale-pop-70) l c h / 0.1);
|
||||
|
||||
outline-color: var(--clr-scale-pop-40);
|
||||
outline-style: dashed;
|
||||
outline-width: 1px;
|
||||
outline-offset: -10px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dropzone-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--clr-scale-pop-40);
|
||||
}
|
||||
|
||||
.small {
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.medium {
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
.large {
|
||||
border-radius: var(--radius-l);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -6,21 +6,14 @@
|
||||
|
||||
interface Props {
|
||||
width?: 'default' | 'small' | 'large';
|
||||
title?: string | undefined;
|
||||
icon?: keyof typeof iconsJson | undefined;
|
||||
title?: string;
|
||||
icon?: keyof typeof iconsJson;
|
||||
onclose?: () => void;
|
||||
children: Snippet<[item?: any]>;
|
||||
controls?: Snippet<[close: () => void, item: any]>;
|
||||
}
|
||||
|
||||
const {
|
||||
width = 'default',
|
||||
title = undefined,
|
||||
icon = undefined,
|
||||
onclose,
|
||||
children,
|
||||
controls
|
||||
}: Props = $props();
|
||||
const { width = 'default', title, icon, onclose, children, controls }: Props = $props();
|
||||
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let item = $state<any>();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { dzRegistry } from './dropzone';
|
||||
import { dropzoneRegistry } from './dropzone';
|
||||
import type { Draggable } from './draggables';
|
||||
|
||||
export interface DraggableConfig {
|
||||
@ -79,11 +79,6 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
|
||||
|
||||
let selectedElements: HTMLElement[] = [];
|
||||
|
||||
const onDropListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
|
||||
const onDragLeaveListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
|
||||
const onDragEnterListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
|
||||
const onDragOverListeners = new Map<HTMLElement, Array<(e: DragEvent) => void>>();
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
dragHandle = e.target as HTMLElement;
|
||||
}
|
||||
@ -124,68 +119,13 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
|
||||
|
||||
document.body.appendChild(clone);
|
||||
|
||||
// activate destination zones
|
||||
dzRegistry.forEach(async ([target, dz]) => {
|
||||
if (!dz.accepts(await opts.data)) return;
|
||||
|
||||
async function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dz.onDrop(await opts.data);
|
||||
}
|
||||
|
||||
function onDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.add(dz.hover);
|
||||
}
|
||||
|
||||
function onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
target.classList.remove(dz.hover);
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// keep track of listeners so that we can remove them later
|
||||
if (onDropListeners.has(target)) {
|
||||
onDropListeners.get(target)!.push(onDrop);
|
||||
} else {
|
||||
onDropListeners.set(target, [onDrop]);
|
||||
}
|
||||
|
||||
if (onDragEnterListeners.has(target)) {
|
||||
onDragEnterListeners.get(target)!.push(onDragEnter);
|
||||
} else {
|
||||
onDragEnterListeners.set(target, [onDragEnter]);
|
||||
}
|
||||
|
||||
if (onDragLeaveListeners.has(target)) {
|
||||
onDragLeaveListeners.get(target)!.push(onDragLeave);
|
||||
} else {
|
||||
onDragLeaveListeners.set(target, [onDragLeave]);
|
||||
}
|
||||
|
||||
if (onDragOverListeners.has(target)) {
|
||||
onDragOverListeners.get(target)!.push(onDragOver);
|
||||
} else {
|
||||
onDragOverListeners.set(target, [onDragOver]);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/14203734/dragend-dragenter-and-dragleave-firing-off-immediately-when-i-drag
|
||||
setTimeout(() => {
|
||||
target.classList.add(dz.active);
|
||||
}, 10);
|
||||
|
||||
target.addEventListener('drop', onDrop);
|
||||
target.addEventListener('dragenter', onDragEnter);
|
||||
target.addEventListener('dragleave', onDragLeave);
|
||||
target.addEventListener('dragover', onDragOver);
|
||||
Array.from(dropzoneRegistry.values()).forEach((dropzone) => {
|
||||
dropzone.register(opts.data);
|
||||
});
|
||||
|
||||
// Get chromium to fire dragover & drop events
|
||||
// https://stackoverflow.com/questions/6481094/html5-drag-and-drop-ondragover-not-firing-in-chrome/6483205#6483205
|
||||
e.dataTransfer?.setData('text/html', 'd'); // cannot be empty string
|
||||
e.dataTransfer?.setData('text/html', 'placeholder copy'); // cannot be empty string
|
||||
e.dataTransfer?.setDragImage(clone, e.offsetX + 30, e.offsetY + 30); // Adds the padding
|
||||
e.stopPropagation();
|
||||
}
|
||||
@ -201,25 +141,8 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
|
||||
element.style.opacity = '1';
|
||||
});
|
||||
|
||||
// deactivate destination zones
|
||||
dzRegistry.forEach(async ([node, dz]) => {
|
||||
if (!dz.accepts(await opts.data)) return;
|
||||
// remove all listeners
|
||||
onDropListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('drop', listener);
|
||||
});
|
||||
onDragEnterListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragenter', listener);
|
||||
});
|
||||
onDragLeaveListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragleave', listener);
|
||||
});
|
||||
onDragOverListeners.get(node)?.forEach((listener) => {
|
||||
node.removeEventListener('dragover', listener);
|
||||
});
|
||||
|
||||
node.classList.remove(dz.active);
|
||||
node.classList.remove(dz.hover);
|
||||
Array.from(dropzoneRegistry.values()).forEach((dropzone) => {
|
||||
dropzone.unregister();
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
|
@ -1,53 +1,183 @@
|
||||
export interface Dropzone {
|
||||
export interface DropzoneConfiguration {
|
||||
disabled: boolean;
|
||||
active: string;
|
||||
hover: string;
|
||||
accepts: (data: any) => boolean;
|
||||
onDrop: (data: any) => Promise<void> | void;
|
||||
onActivationStart: () => void;
|
||||
onActivationEnd: () => void;
|
||||
onHoverStart: () => void;
|
||||
onHoverEnd: () => void;
|
||||
target: string;
|
||||
}
|
||||
export class Dropzone {
|
||||
private activated: boolean = false;
|
||||
private hovered: boolean = false;
|
||||
|
||||
private registered: boolean = false;
|
||||
// In order to propperly deregister functions we need to use the same
|
||||
// reference so we must store the function after we bind the reference
|
||||
private registeredOnDrop?: (e: DragEvent) => any;
|
||||
private registeredOnDragEnter?: (e: DragEvent) => any;
|
||||
private registeredOnDragLeave?: (e: DragEvent) => any;
|
||||
|
||||
private target!: HTMLElement;
|
||||
|
||||
private data?: any;
|
||||
|
||||
constructor(
|
||||
private configuration: DropzoneConfiguration,
|
||||
private rootNode: HTMLElement
|
||||
) {
|
||||
// Sets this.target
|
||||
this.setTarget();
|
||||
}
|
||||
|
||||
const defaultDropzoneOptions: Dropzone = {
|
||||
disabled: false,
|
||||
active: 'dropzone-active',
|
||||
hover: 'dropzone-hover',
|
||||
accepts: (data) => data === 'default',
|
||||
onDrop: () => {}
|
||||
};
|
||||
async register(data: any) {
|
||||
this.data = data;
|
||||
|
||||
export function dropzone(node: HTMLElement, opts: Partial<Dropzone> | undefined) {
|
||||
let currentOptions = { ...defaultDropzoneOptions, ...opts };
|
||||
if (!this.configuration.accepts(await this.data)) return;
|
||||
if (this.registered) {
|
||||
this.unregister();
|
||||
}
|
||||
this.registered = true;
|
||||
|
||||
function setup(opts: Partial<Dropzone> | undefined) {
|
||||
currentOptions = { ...defaultDropzoneOptions, ...opts };
|
||||
if (currentOptions.disabled) return;
|
||||
this.registerListeners();
|
||||
|
||||
register(node, currentOptions);
|
||||
// Mark the dropzone as active
|
||||
setTimeout(() => {
|
||||
this.configuration.onActivationStart();
|
||||
this.activated = true;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
unregister() {
|
||||
// Mark as no longer active and ensure its not stuck in the hover state
|
||||
this.activated = false;
|
||||
this.configuration.onActivationEnd();
|
||||
this.configuration.onHoverEnd();
|
||||
|
||||
this.unregisterListeners();
|
||||
|
||||
this.registered = false;
|
||||
}
|
||||
|
||||
// Designed to quickly swap out the configuration
|
||||
async reregister(configuration: DropzoneConfiguration) {
|
||||
this.unregisterListeners();
|
||||
|
||||
// On the previous configuration, mark configuration as deactivated and unhovered
|
||||
this.configuration.onActivationEnd();
|
||||
this.configuration.onHoverEnd();
|
||||
|
||||
this.configuration = configuration;
|
||||
this.setTarget();
|
||||
|
||||
if (!this.configuration.accepts(await this.data)) return;
|
||||
|
||||
if (this.hovered) {
|
||||
this.configuration.onHoverStart();
|
||||
} else {
|
||||
this.configuration.onHoverEnd();
|
||||
}
|
||||
|
||||
if (this.activated) {
|
||||
this.configuration.onActivationStart();
|
||||
} else {
|
||||
this.configuration.onActivationEnd();
|
||||
}
|
||||
this.registerListeners();
|
||||
}
|
||||
|
||||
private registerListeners() {
|
||||
this.registeredOnDrop = this.onDrop.bind(this);
|
||||
this.registeredOnDragEnter = this.onDragEnter.bind(this);
|
||||
this.registeredOnDragLeave = this.onDragLeave.bind(this);
|
||||
|
||||
this.target.addEventListener('drop', this.registeredOnDrop);
|
||||
this.target.addEventListener('dragenter', this.registeredOnDragEnter);
|
||||
this.target.addEventListener('dragleave', this.registeredOnDragLeave);
|
||||
}
|
||||
|
||||
private unregisterListeners() {
|
||||
if (this.registeredOnDrop) {
|
||||
this.target.removeEventListener('drop', this.registeredOnDrop);
|
||||
}
|
||||
if (this.registeredOnDragEnter) {
|
||||
this.target.removeEventListener('dragenter', this.registeredOnDragEnter);
|
||||
}
|
||||
if (this.registeredOnDragLeave) {
|
||||
this.target.removeEventListener('dragleave', this.registeredOnDragLeave);
|
||||
}
|
||||
}
|
||||
|
||||
private setTarget() {
|
||||
const child = this.rootNode.querySelector<HTMLElement>(this.configuration.target);
|
||||
|
||||
if (child) {
|
||||
this.target = child;
|
||||
} else {
|
||||
this.target = this.rootNode;
|
||||
}
|
||||
}
|
||||
|
||||
private async onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!this.activated) return;
|
||||
this.configuration.onDrop(await this.data);
|
||||
}
|
||||
|
||||
private onDragEnter(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!this.activated) return;
|
||||
|
||||
this.hovered = true;
|
||||
this.configuration.onHoverStart();
|
||||
}
|
||||
|
||||
private onDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!this.activated) return;
|
||||
|
||||
this.hovered = false;
|
||||
this.configuration.onHoverEnd();
|
||||
}
|
||||
}
|
||||
|
||||
export const dropzoneRegistry = new Map<HTMLElement, Dropzone>();
|
||||
|
||||
export function dropzone(node: HTMLElement, configuration: DropzoneConfiguration) {
|
||||
function setup(configuration: DropzoneConfiguration) {
|
||||
if (configuration.disabled) return;
|
||||
|
||||
if (dropzoneRegistry.has(node)) {
|
||||
clean();
|
||||
}
|
||||
|
||||
dropzoneRegistry.set(node, new Dropzone(configuration, node));
|
||||
}
|
||||
|
||||
function clean() {
|
||||
unregister(currentOptions);
|
||||
dropzoneRegistry.get(node)?.unregister();
|
||||
dropzoneRegistry.delete(node);
|
||||
}
|
||||
|
||||
setup(opts);
|
||||
setup(configuration);
|
||||
|
||||
function update(configuration: DropzoneConfiguration) {
|
||||
const dropzone = dropzoneRegistry.get(node);
|
||||
|
||||
if (dropzone) {
|
||||
dropzone.reregister(configuration);
|
||||
} else {
|
||||
setup(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update(opts: Partial<Dropzone> | undefined) {
|
||||
clean();
|
||||
setup(opts);
|
||||
update(configuration: DropzoneConfiguration) {
|
||||
update(configuration);
|
||||
},
|
||||
destroy() {
|
||||
clean();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const dzRegistry: [HTMLElement, Dropzone][] = [];
|
||||
|
||||
function register(node: HTMLElement, dropzone: Dropzone) {
|
||||
dzRegistry.push([node, dropzone]);
|
||||
}
|
||||
|
||||
function unregister(dropzone: Dropzone) {
|
||||
const index = dzRegistry.findIndex(([, dz]) => dz === dropzone);
|
||||
if (index >= 0) dzRegistry.splice(index, 1);
|
||||
}
|
||||
|
@ -1,73 +0,0 @@
|
||||
import type { Commit } from '$lib/vbranches/types';
|
||||
|
||||
/**
|
||||
* This class is used to determine how far a commit has been drag and dropped.
|
||||
*
|
||||
* We expect the dropzones to be in the following order:
|
||||
*
|
||||
* ```
|
||||
* const indexer = new ReorderDropzoneIndexer(commits);
|
||||
*
|
||||
* <ReorderDropzone index={indexer.topDropzoneIndex} />
|
||||
* <Commit id={commits[0].id} />
|
||||
* <ReorderDropzone index={indexer.dropzoneIndexBelowCommit(commits[0].id)} />
|
||||
* <Commit id={commits[1].id} />
|
||||
* <ReorderDropzone index={indexer.dropzoneIndexBelowCommit(commits[1].id)} />
|
||||
* ```
|
||||
*/
|
||||
export class ReorderDropzoneIndexer {
|
||||
private dropzoneIndexes = new Map<string, number>();
|
||||
private commitIndexes = new Map<string, number>();
|
||||
|
||||
constructor(commits: Commit[]) {
|
||||
this.dropzoneIndexes.set('top', 0);
|
||||
|
||||
commits.forEach((commit, index) => {
|
||||
this.dropzoneIndexes.set(commit.id, index + 1);
|
||||
this.commitIndexes.set(commit.id, index);
|
||||
});
|
||||
}
|
||||
|
||||
get topDropzoneIndex() {
|
||||
return this.dropzoneIndexes.get('top') ?? 0;
|
||||
}
|
||||
|
||||
dropzoneIndexBelowCommit(commitId: string) {
|
||||
const index = this.dropzoneIndexes.get(commitId);
|
||||
|
||||
if (index === undefined) {
|
||||
throw new Error(`Commit ${commitId} not found in dropzoneIndexes`);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
commitIndex(commitId: string) {
|
||||
const index = this.commitIndexes.get(commitId);
|
||||
|
||||
if (index === undefined) {
|
||||
throw new Error(`Commit ${commitId} not found in commitIndexes`);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* A negative offset means the commit has been dragged up, and a positive offset means the commit has been dragged down.
|
||||
*/
|
||||
dropzoneCommitOffset(dropzoneIndex: number, commitId: string) {
|
||||
const commitIndex = this.commitIndexes.get(commitId);
|
||||
|
||||
if (commitIndex === undefined) {
|
||||
throw new Error(`Commit ${commitId} not found in commitIndexes`);
|
||||
}
|
||||
|
||||
const offset = dropzoneIndex - commitIndex;
|
||||
|
||||
if (offset > 0) {
|
||||
return offset - 1;
|
||||
} else {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
}
|
127
app/src/lib/dragging/reorderDropzoneManager.ts
Normal file
127
app/src/lib/dragging/reorderDropzoneManager.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { DraggableCommit } from '$lib/dragging/draggables';
|
||||
import type { BranchController } from '$lib/vbranches/branchController';
|
||||
import type { Branch, Commit } from '$lib/vbranches/types';
|
||||
|
||||
// Exported for type access only
|
||||
export class ReorderDropzone {
|
||||
constructor(
|
||||
private branchController: BranchController,
|
||||
private branch: Branch,
|
||||
private entry: Entry
|
||||
) {}
|
||||
|
||||
accepts(data: any) {
|
||||
if (!(data instanceof DraggableCommit)) return false;
|
||||
if (data.branchId !== this.branch.id) return false;
|
||||
if (this.entry.distanceToOtherCommit(data.commit.id) === 0) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onDrop(data: any) {
|
||||
if (!(data instanceof DraggableCommit)) return;
|
||||
if (data.branchId !== this.branch.id) return;
|
||||
|
||||
const offset = this.entry.distanceToOtherCommit(data.commit.id);
|
||||
this.branchController.reorderCommit(this.branch.id, data.commit.id, offset);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReorderDropzoneManager {
|
||||
private indexer: Indexer;
|
||||
|
||||
constructor(
|
||||
private branchController: BranchController,
|
||||
private branch: Branch,
|
||||
commits: Commit[]
|
||||
) {
|
||||
this.indexer = new Indexer(commits);
|
||||
}
|
||||
|
||||
get topDropzone() {
|
||||
const entry = this.indexer.get('top');
|
||||
|
||||
return new ReorderDropzone(this.branchController, this.branch, entry);
|
||||
}
|
||||
|
||||
dropzoneBelowCommit(commitId: string) {
|
||||
const entry = this.indexer.get(commitId);
|
||||
|
||||
return new ReorderDropzone(this.branchController, this.branch, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export class ReorderDropzoneManagerFactory {
|
||||
constructor(private branchController: BranchController) {}
|
||||
|
||||
build(branch: Branch, commits: Commit[]) {
|
||||
return new ReorderDropzoneManager(this.branchController, branch, commits);
|
||||
}
|
||||
}
|
||||
|
||||
// Private classes used to calculate distances between commtis
|
||||
class Indexer {
|
||||
private dropzoneIndexes = new Map<string, number>();
|
||||
private commitIndexes = new Map<string, number>();
|
||||
|
||||
constructor(commits: Commit[]) {
|
||||
this.dropzoneIndexes.set('top', 0);
|
||||
|
||||
commits.forEach((commit, index) => {
|
||||
this.dropzoneIndexes.set(commit.id, index + 1);
|
||||
this.commitIndexes.set(commit.id, index);
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
const index = this.getIndex(key);
|
||||
|
||||
return new Entry(this.commitIndexes, index);
|
||||
}
|
||||
|
||||
private getIndex(key: string) {
|
||||
if (key === 'top') {
|
||||
return this.dropzoneIndexes.get(key) ?? 0;
|
||||
} else {
|
||||
const index = this.dropzoneIndexes.get(key);
|
||||
|
||||
if (index === undefined) {
|
||||
throw new Error(`Commit ${key} not found in dropzoneIndexes`);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Entry {
|
||||
constructor(
|
||||
private commitIndexes: Map<string, number>,
|
||||
private index: number
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A negative offset means the commit has been dragged up, and a positive offset means the commit has been dragged down.
|
||||
*/
|
||||
distanceToOtherCommit(commitId: string) {
|
||||
const commitIndex = this.commitIndex(commitId);
|
||||
|
||||
const offset = this.index - commitIndex;
|
||||
|
||||
if (offset > 0) {
|
||||
return offset - 1;
|
||||
} else {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
|
||||
private commitIndex(commitId: string) {
|
||||
const index = this.commitIndexes.get(commitId);
|
||||
|
||||
if (index === undefined) {
|
||||
throw new Error(`Commit ${commitId} not found in commitIndexes`);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { listen } from '$lib/backend/ipc';
|
||||
import { Project } from '$lib/backend/projects';
|
||||
import { BranchDragActionsFactory } from '$lib/branches/dragActions';
|
||||
import { BranchService } from '$lib/branches/service';
|
||||
import { CommitDragActionsFactory } from '$lib/commits/dragActions';
|
||||
import History from '$lib/components/History.svelte';
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import NoBaseBranch from '$lib/components/NoBaseBranch.svelte';
|
||||
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
|
||||
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
|
||||
import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte';
|
||||
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
|
||||
import { HistoryService } from '$lib/history/history';
|
||||
import { persisted } from '$lib/persisted/persisted';
|
||||
import * as events from '$lib/utils/events';
|
||||
@ -30,7 +33,10 @@
|
||||
baseBranchService,
|
||||
gbBranchActive$,
|
||||
branchService,
|
||||
branchController
|
||||
branchController,
|
||||
branchDragActionsFactory,
|
||||
commitDragActionsFactory,
|
||||
reorderDropzoneManagerFactory
|
||||
} = data);
|
||||
|
||||
$: branchesError = vbranchService.branchesError;
|
||||
@ -45,6 +51,9 @@
|
||||
$: setContext(BaseBranchService, baseBranchService);
|
||||
$: setContext(BaseBranch, baseBranch);
|
||||
$: setContext(Project, project);
|
||||
$: setContext(BranchDragActionsFactory, branchDragActionsFactory);
|
||||
$: setContext(CommitDragActionsFactory, commitDragActionsFactory);
|
||||
$: setContext(ReorderDropzoneManagerFactory, reorderDropzoneManagerFactory);
|
||||
|
||||
const showHistoryView = persisted(false, 'showHistoryView');
|
||||
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { invoke } from '$lib/backend/ipc';
|
||||
import { BranchDragActionsFactory } from '$lib/branches/dragActions.js';
|
||||
import { BranchService } from '$lib/branches/service';
|
||||
import { CommitDragActionsFactory } from '$lib/commits/dragActions.js';
|
||||
import { ReorderDropzoneManagerFactory } from '$lib/dragging/reorderDropzoneManager';
|
||||
import { HistoryService } from '$lib/history/history';
|
||||
import { getFetchNotifications } from '$lib/stores/fetches';
|
||||
import { getHeads } from '$lib/stores/head';
|
||||
@ -63,6 +66,10 @@ export async function load({ params, parent }) {
|
||||
branchController
|
||||
);
|
||||
|
||||
const branchDragActionsFactory = new BranchDragActionsFactory(branchController);
|
||||
const commitDragActionsFactory = new CommitDragActionsFactory(branchController, project);
|
||||
const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController);
|
||||
|
||||
return {
|
||||
authService,
|
||||
baseBranchService,
|
||||
@ -76,6 +83,9 @@ export async function load({ params, parent }) {
|
||||
vbranchService,
|
||||
|
||||
// These observables are provided for convenience
|
||||
gbBranchActive$
|
||||
gbBranchActive$,
|
||||
branchDragActionsFactory,
|
||||
commitDragActionsFactory,
|
||||
reorderDropzoneManagerFactory
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user