diff --git a/packages/ui/src/lib/utils/draggable.ts b/packages/ui/src/lib/utils/draggable.ts new file mode 100644 index 000000000..0f201cedc --- /dev/null +++ b/packages/ui/src/lib/utils/draggable.ts @@ -0,0 +1,201 @@ +export interface Dropzone { + disabled: boolean; + active: string; + hover: string; + accepts: (data: any) => boolean; + onDrop: (data: any) => Promise | void; +} + +const defaultDropzoneOptions: Dropzone = { + disabled: false, + active: 'dropzone-active', + hover: 'dropzone-hover', + accepts: (data) => data == 'default', + onDrop: () => {} +}; + +export function dropzone(node: HTMLElement, opts: Partial | undefined) { + let currentOptions = { ...defaultDropzoneOptions, ...opts }; + + function handleDragEnter(e: DragEvent) { + if (activeZones.has(node)) { + node.classList.add(currentOptions.hover); + e.preventDefault(); + } + } + + function handleDragLeave(_e: DragEvent) { + if (activeZones.has(node)) { + node.classList.remove(currentOptions.hover); + } + } + + function handleDragOver(e: DragEvent) { + if (activeZones.has(node)) { + e.preventDefault(); + } + } + + function setup(opts: Partial | undefined) { + currentOptions = { ...defaultDropzoneOptions, ...opts }; + if (currentOptions.disabled) return; + + register(node, currentOptions); + + node.addEventListener('dragenter', handleDragEnter); + node.addEventListener('dragleave', handleDragLeave); + node.addEventListener('dragover', handleDragOver); + } + + function clean() { + unregister(currentOptions); + + node.removeEventListener('dragenter', handleDragEnter); + node.removeEventListener('dragleave', handleDragLeave); + node.removeEventListener('dragover', handleDragOver); + } + + setup(opts); + + return { + update(opts: Partial | undefined) { + clean(); + setup(opts); + }, + destroy() { + clean(); + } + }; +} + +const registry: [HTMLElement, Dropzone][] = []; + +const activeZones = new Set(); + +function register(node: HTMLElement, dropzone: Dropzone) { + registry.push([node, dropzone]); +} + +function unregister(dropzone: Dropzone) { + const index = registry.findIndex(([, dz]) => dz == dropzone); + if (index >= 0) registry.splice(index, 1); +} + +export interface Draggable { + data: any; + disabled: boolean; +} + +const defaultDraggableOptions: Draggable = { + data: 'default', + disabled: false +}; + +export function draggable(node: HTMLElement, opts: Partial | undefined) { + let currentOptions = { ...defaultDraggableOptions, ...opts }; + let clone: HTMLElement; + + const onDropListeners = new Map void>>(); + + /** + * The problem with the ghost element is that it gets clipped after rotation unless we enclose + * it within a larger bounding box. This means we have an extra `
` in the html that is + * only present to support the rotation + */ + function handleDragStart(e: DragEvent) { + // Start by cloning the node for the ghost element + clone = node.cloneNode(true) as HTMLElement; + clone.style.position = 'absolute'; + clone.style.top = '-9999px'; // Element has to be in the DOM so we move it out of sight + clone.style.display = 'inline-block'; + clone.style.padding = '30px'; // To prevent clipping of rotated element + + // Style the inner node so it retains the shape and then rotate + const inner = clone.children[0] as HTMLElement; + inner.style.height = node.clientHeight + 'px'; + inner.style.width = node.clientWidth + 'px'; + inner.style.rotate = `${Math.floor(Math.random() * 3)}deg`; + document.body.appendChild(clone); + + // Dim the original element while dragging + node.style.opacity = '0.6'; + + // activate destination zones + registry + .filter(([_node, dz]) => dz.accepts(currentOptions.data)) + .forEach(([target, dz]) => { + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dz.onDrop(currentOptions.data); + }; + + // 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]); + } + + // 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); + activeZones.add(target); + }); + + e.dataTransfer?.setDragImage(clone, e.offsetX + 30, e.offsetY + 30); // Adds the padding + e.stopPropagation(); + } + + function handleDragEnd(e: DragEvent) { + node.style.opacity = '1'; + clone.remove(); + + // deactivate destination zones + registry + .filter(([_node, dz]) => dz.accepts(currentOptions.data)) + .forEach(([node, dz]) => { + // remove all listeners + const onDrop = onDropListeners.get(node); + if (onDrop) { + onDrop.forEach((listener) => { + node.removeEventListener('drop', listener); + }); + } + + node.classList.remove(dz.active); + activeZones.delete(node); + }); + + e.stopPropagation(); + } + + function setup(opts: Partial | undefined) { + currentOptions = { ...defaultDraggableOptions, ...opts }; + + if (currentOptions.disabled) return; + + node.draggable = true; + + node.addEventListener('dragstart', handleDragStart); + node.addEventListener('dragend', handleDragEnd); + } + + function clean() { + node.draggable = false; + node.removeEventListener('dragstart', handleDragStart); + node.removeEventListener('dragend', handleDragEnd); + } + + setup(opts); + + return { + update(opts: Partial | undefined) { + clean(); + setup(opts); + }, + destroy() { + clean(); + } + }; +} diff --git a/packages/ui/src/lib/utils/dropZone.ts b/packages/ui/src/lib/utils/dropZone.ts deleted file mode 100644 index 69d3d16a5..000000000 --- a/packages/ui/src/lib/utils/dropZone.ts +++ /dev/null @@ -1,175 +0,0 @@ -const zoneMap = new Map>(); -const optionsMap = new Map(); - -export interface DzOptions { - type: string; - hover: string; - active: string; -} - -const defaultOptions: DzOptions = { - hover: 'drop-zone-hover', - active: 'drop-zone-active', - type: 'default' -}; - -function inactivateZones(zones: Set) { - zones?.forEach((zone) => { - const opts = optionsMap.get(zone); - opts && zone.classList.remove(opts.active); - }); -} - -function activateZones(zones: Set, activeZone: HTMLElement) { - zones?.forEach((zone) => { - if (zone !== activeZone && !isChildOf(activeZone, zone)) { - const opts = optionsMap.get(zone); - opts && zone.classList.add(opts.active); - } - }); -} - -function getZones(type: string): Set { - let zones = zoneMap.get(type); - if (!zones) { - zones = new Set([]); - zoneMap.set(type, zones); - } - return zones; -} - -function isChildOf(child: any, parent: HTMLElement): boolean { - if (parent === child) return false; - if (!child.parentElement) return false; - if (child.parentElement == parent) return true; - return isChildOf(child.parentElement, parent); -} - -export function dzTrigger(node: HTMLElement, opts: Partial | undefined) { - const options = { ...defaultOptions, ...opts }; - const zones = getZones(options.type); - - let clone: HTMLElement; - - /** - * The problem with the ghost element is that it gets clipped after rotation unless we enclose - * it within a larger bounding box. This means we have an extra `
` in the html that is - * only present to support the rotation - */ - function handleDragStart(e: DragEvent) { - // Start by cloning the node for the ghost element - clone = node.cloneNode(true) as HTMLElement; - clone.style.position = 'absolute'; - clone.style.top = '-9999px'; // Element has to be in the DOM so we move it out of sight - clone.style.display = 'inline-block'; - clone.style.padding = '30px'; // To prevent clipping of rotated element - - // Style the inner node so it retains the shape and then rotate - const inner = clone.children[0] as HTMLElement; - inner.style.height = node.clientHeight + 'px'; - inner.style.width = node.clientWidth + 'px'; - inner.style.rotate = `${Math.floor(Math.random() * 3)}deg`; - document.body.appendChild(clone); - - // Dim the original element while dragging - node.style.opacity = '0.6'; - - e.dataTransfer?.setDragImage(clone, e.offsetX + 30, e.offsetY + 30); // Adds the padding - activateZones(zones, node); - e.stopPropagation(); - } - - function handleDragEnd(e: DragEvent) { - node.style.opacity = '1'; // Undo the dimming from `dragstart` - clone.remove(); // Remove temporary ghost element - - e.stopPropagation(); - inactivateZones(zones); - } - - node.addEventListener('dragstart', handleDragStart); - node.addEventListener('dragend', handleDragEnd); - - return { - destroy() { - node.removeEventListener('dragstart', handleDragStart); - node.removeEventListener('dragend', handleDragEnd); - } - }; -} - -export function dzHighlight(node: HTMLElement, opts: Partial | undefined) { - const options = { ...defaultOptions, ...opts }; - const zones = getZones(options.type); - zones.add(node); - optionsMap.set(node, options); - - function setHover(value: boolean) { - if (value) { - // We do this so we can set pointer-events-none on all dropzones from main css file, - // without it onMouseLeave fires every time a child container is left. - node.classList.add(defaultOptions.hover); - node.classList.add(options.hover); - } else { - node.classList.remove(defaultOptions.hover); - node.classList.remove(options.hover); - } - } - - function handleDragEnter(e: DragEvent) { - if (!e.dataTransfer?.types.includes(options.type)) { - return; - } - setHover(true); - e.stopPropagation(); - } - - function handleDragLeave(e: DragEvent) { - if (!e.dataTransfer?.types.includes(options.type)) { - return; - } - if (!isChildOf(e.target, node)) { - setHover(false); - } - e.stopPropagation(); - } - - function handleDragEnd(e: DragEvent) { - setHover(false); - inactivateZones(zones); - e.stopPropagation(); - } - - function handleDrop(e: DragEvent) { - if (!e.dataTransfer?.types.includes(options.type)) { - return; - } - setHover(false); - inactivateZones(zones); - } - - function handleDragOver(e: DragEvent) { - if (!e.dataTransfer?.types.includes(options.type)) { - e.stopImmediatePropagation(); // Stops event from reaching `on:dragover` on the element - } - if (e.dataTransfer?.types.includes(options.type)) e.preventDefault(); - } - - node.addEventListener('dragend', handleDragEnd); - node.addEventListener('dragenter', handleDragEnter); - node.addEventListener('dragleave', handleDragLeave); - node.addEventListener('dragover', handleDragOver); - node.addEventListener('drop', handleDrop); - node.classList.add('drop-zone'); - - return { - destroy() { - node.removeEventListener('dragend', handleDragEnd); - node.removeEventListener('dragenter', handleDragEnter); - node.removeEventListener('dragleave', handleDragLeave); - node.removeEventListener('dragover', handleDragOver); - node.removeEventListener('drop', handleDrop); - zones?.delete(node); - } - }; -} diff --git a/packages/ui/src/lib/vbranches/branchController.ts b/packages/ui/src/lib/vbranches/branchController.ts index 163570022..76aa63335 100644 --- a/packages/ui/src/lib/vbranches/branchController.ts +++ b/packages/ui/src/lib/vbranches/branchController.ts @@ -32,9 +32,13 @@ export class BranchController { } } - async resetBranch(params: { branchId: string; projectId: string; targetCommitOid: string }) { + async resetBranch(branchId: string, targetCommitOid: string) { try { - await invoke('reset_virtual_branch', params); + await invoke('reset_virtual_branch', { + branchId, + projectId: this.projectId, + targetCommitOid + }); await this.virtualBranchStore.reload(); } catch (err) { toasts.error('Failed to reset branch'); @@ -50,9 +54,14 @@ export class BranchController { } } - async commitBranch(params: { branch: string; message: string; ownership?: string }) { + async commitBranch(branch: string, message: string, ownership: string | undefined = undefined) { try { - await invoke('commit_virtual_branch', { projectId: this.projectId, ...params }); + await invoke('commit_virtual_branch', { + projectId: this.projectId, + branch, + message, + ownership + }); await this.virtualBranchStore.reload(); } catch (err) { toasts.error('Failed to commit branch'); @@ -160,9 +169,9 @@ export class BranchController { await this.virtualBranchStore.reload(); } - async pushBranch(params: { branchId: string; withForce: boolean }) { + async pushBranch(branchId: string, withForce: boolean) { try { - await invoke('push_virtual_branch', { projectId: this.projectId, ...params }); + await invoke('push_virtual_branch', { projectId: this.projectId, branchId, withForce }); await this.virtualBranchStore.reload(); } catch (err: any) { if (err.code === 'errors.git.authentication') { @@ -223,11 +232,12 @@ export class BranchController { } } - async cherryPick(params: { branchId: string; targetCommitOid: string }) { + async cherryPick(branchId: string, targetCommitOid: string) { try { await invoke('cherry_pick_onto_virtual_branch', { projectId: this.projectId, - ...params + branchId, + targetCommitOid }); await this.targetBranchStore.reload(); } catch (err: any) { @@ -235,12 +245,25 @@ export class BranchController { } } - async markResolved(projectId: string, path: string) { + async markResolved(path: string) { try { - await invoke('mark_resolved', { projectId, path }); + await invoke('mark_resolved', { projectId: this.projectId, path }); await this.virtualBranchStore.reload(); } catch (err) { toasts.error(`Failed to mark file resolved`); } } + + async amendBranch(branchId: string, ownership: string) { + try { + await invoke('amend_virtual_branch', { + projectId: this.projectId, + branchId, + ownership + }); + await this.targetBranchStore.reload(); + } catch (err: any) { + toasts.error(`Failed to amend commit: ${err.message}`); + } + } } diff --git a/packages/ui/src/routes/[projectId]/board/Board.svelte b/packages/ui/src/routes/[projectId]/board/Board.svelte index b73a20a88..a85ba4d28 100644 --- a/packages/ui/src/routes/[projectId]/board/Board.svelte +++ b/packages/ui/src/routes/[projectId]/board/Board.svelte @@ -2,7 +2,6 @@ import BranchLane from '../components/BranchLane.svelte'; import NewBranchDropZone from './NewBranchDropZone.svelte'; import type { BaseBranch, Branch } from '$lib/vbranches/types'; - import { dzHighlight } from '$lib/utils/dropZone'; import type { BranchController } from '$lib/vbranches/branchController'; import type { getCloudApiClient } from '$lib/backend/cloud'; import type { LoadState } from '@square/svelte-store'; @@ -30,8 +29,6 @@ let priorPosition = 0; let dropPosition = 0; - const dzType = 'text/branch'; - function handleEmpty() { const emptyIndex = branches?.findIndex((item) => !item.files || item.files.length == 0); if (emptyIndex && emptyIndex != -1) { @@ -49,12 +46,14 @@
Something went wrong...
{:else if branches}
{ + if (!dragged) return; + + e.preventDefault(); const children = [...e.currentTarget.children]; dropPosition = 0; // We account for the NewBranchDropZone by subtracting 2 @@ -73,8 +72,10 @@ : children[dropPosition].after(dragged); } }} - on:drop={() => { + on:drop={(e) => { + if (!dragged) return; if (!branches) return; + e.preventDefault(); if (priorPosition != dropPosition) { const el = branches.splice(priorPosition, 1); branches.splice(dropPosition, 0, ...el); @@ -87,24 +88,31 @@ }} > {#each branches.filter((c) => c.active) as branch (branch.id)} - { - if (!e.dataTransfer) return; - e.dataTransfer.setData(dzType, branch.id); dragged = e.currentTarget; priorPosition = Array.from(dropZone.children).indexOf(dragged); }} - on:empty={handleEmpty} - {branch} - {projectId} - {projectPath} - {base} - {cloudEnabled} - {cloud} - {branchController} - branchCount={branches.filter((c) => c.active).length} - {githubContext} - /> + on:dragend={() => { + dragged = undefined; + }} + > + c.active).length} + {githubContext} + /> +
{/each} {#if !activeBranches || activeBranches.length == 0} diff --git a/packages/ui/src/routes/[projectId]/board/NewBranchDropZone.svelte b/packages/ui/src/routes/[projectId]/board/NewBranchDropZone.svelte index d1b7d8211..62243bd4f 100644 --- a/packages/ui/src/routes/[projectId]/board/NewBranchDropZone.svelte +++ b/packages/ui/src/routes/[projectId]/board/NewBranchDropZone.svelte @@ -1,20 +1,34 @@
{ - if (!e.dataTransfer) { - return; - } - const ownership = e.dataTransfer.getData('text/hunk'); - branchController.createBranch({ ownership }); + use:dropzone={{ + active: 'new-dz-active', + hover: 'new-dz-hover', + onDrop, + accepts }} >
+ import { dropzone } from '$lib/utils/draggable'; + import type { Hunk, File, RemoteCommit } from '$lib/vbranches/types'; + import type { BranchController } from '$lib/vbranches/branchController'; + import CommitCard from './CommitCard.svelte'; + + export let branchController: BranchController; + export let branchId: string; + export let commit: RemoteCommit; + export let projectId: string; + + function acceptBranchDrop(data: { branchId: string; file?: File; hunk?: Hunk }) { + if (data.branchId !== branchId) return false; + return !!data.file || !!data.hunk; + } + + function onDrop(data: { file?: File; hunk?: Hunk }) { + if (data.hunk) { + const newOwnership = `${data.hunk.filePath}:${data.hunk.id}`; + branchController.amendBranch(branchId, newOwnership); + } else if (data.file) { + const newOwnership = `${data.file.path}:${data.file.hunks.map(({ id }) => id).join(',')}`; + branchController.amendBranch(branchId, newOwnership); + } + } + + +
+ + + +
diff --git a/packages/ui/src/routes/[projectId]/components/BranchLane.svelte b/packages/ui/src/routes/[projectId]/components/BranchLane.svelte index 048749671..58b1874c4 100644 --- a/packages/ui/src/routes/[projectId]/components/BranchLane.svelte +++ b/packages/ui/src/routes/[projectId]/components/BranchLane.svelte @@ -1,12 +1,12 @@ -
{ - if (!e.dataTransfer) { - return; - } - const data = e.dataTransfer.getData(dzType); - const [newFileId, newHunks] = data.split(':'); - const existingHunkIds = - branch.files.find((f) => f.id === newFileId)?.hunks.map((h) => h.id) || []; - const newHunkIds = newHunks.split(',').filter((h) => !existingHunkIds.includes(h)); - if (newHunkIds.length == 0) { - // don't allow dropping hunk to the lane where it already is - return; - } - e.stopPropagation(); - branchController.updateBranchOwnership(branch.id, (data + '\n' + branch.ownership).trim()); - }} -> +
{#if upstreamCommitsShown}
- {#each branch.upstream.commits as commit} -
e.dataTransfer?.setData('commit/upstream', commit.id)} - > + {#each branch.upstream.commits as commit (commit.id)} +
{/each} @@ -488,25 +482,17 @@ />
{ - if (!e.dataTransfer) { - return; - } - const targetCommitOid = e.dataTransfer.getData('commit/upstream'); - if (!targetCommitOid) { - return; - } - e.stopPropagation(); - branchController.cherryPick({ - targetCommitOid, - branchId: branch.id - }); + use:dropzone={{ + hover: 'lane-dz-hover', + active: 'lane-dz-active', + accepts: acceptBranchDrop, + onDrop: onBranchDrop }} > @@ -550,9 +536,8 @@ expanded={file.expanded} conflicted={file.conflicted} {selectedOwnership} + branchId={branch.id} {file} - {dzType} - {projectId} {projectPath} {branchController} selectable={commitDialogShown} @@ -664,7 +649,16 @@
{/if} - + {#if branch.commits.at(0)?.id === commit.id} + + {:else} + + {/if}
{/each}
@@ -751,7 +745,16 @@ />
{/if} - + {#if branch.commits.at(0)?.id === commit.id} + + {:else} + + {/if}
{/each}
diff --git a/packages/ui/src/routes/[projectId]/components/CommitCard.svelte b/packages/ui/src/routes/[projectId]/components/CommitCard.svelte index 7ade93f58..246c09d84 100644 --- a/packages/ui/src/routes/[projectId]/components/CommitCard.svelte +++ b/packages/ui/src/routes/[projectId]/components/CommitCard.svelte @@ -32,7 +32,9 @@ } -
+
@@ -194,13 +189,14 @@ class="bg-6 border-color-3 my-1 flex w-full flex-col overflow-hidden rounded border" >
{ - if ('hunk' in section) - e.dataTransfer?.setData('text/hunk', file.id + ':' + section.hunk.id); + use:draggable={{ + data: { + branchId, + hunk: section.hunk + }, + disabled: readonly }} on:dblclick class="changed-hunk"