| b69ab31 | | | 1 | /** |
| b69ab31 | | | 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| b69ab31 | | | 3 | * |
| b69ab31 | | | 4 | * This source code is licensed under the MIT license found in the |
| b69ab31 | | | 5 | * LICENSE file in the root directory of this source tree. |
| b69ab31 | | | 6 | */ |
| b69ab31 | | | 7 | |
| b69ab31 | | | 8 | import type {CommitInfo} from './types'; |
| b69ab31 | | | 9 | |
| b69ab31 | | | 10 | import {Tooltip} from 'isl-components/Tooltip'; |
| b69ab31 | | | 11 | import {useCallback, useEffect, useState} from 'react'; |
| b69ab31 | | | 12 | import {t} from './i18n'; |
| b69ab31 | | | 13 | import {readAtom, writeAtom} from './jotaiUtils'; |
| b69ab31 | | | 14 | import {REBASE_PREVIEW_HASH_PREFIX, RebaseOperation} from './operations/RebaseOperation'; |
| b69ab31 | | | 15 | import {operationBeingPreviewed} from './operationsState'; |
| b69ab31 | | | 16 | import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from './previews'; |
| b69ab31 | | | 17 | import {latestDag} from './serverAPIState'; |
| b69ab31 | | | 18 | import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils'; |
| b69ab31 | | | 19 | import {succeedableRevset} from './types'; |
| b69ab31 | | | 20 | |
| b69ab31 | | | 21 | function isDraggablePreview(previewType?: CommitPreview): boolean { |
| b69ab31 | | | 22 | switch (previewType) { |
| b69ab31 | | | 23 | // dragging preview descendants would be confusing (it would reset part of your drag), |
| b69ab31 | | | 24 | // you probably meant to drag the root. |
| b69ab31 | | | 25 | case CommitPreview.REBASE_DESCENDANT: |
| b69ab31 | | | 26 | // old commits are already being dragged |
| b69ab31 | | | 27 | case CommitPreview.REBASE_OLD: |
| b69ab31 | | | 28 | case CommitPreview.HIDDEN_ROOT: |
| b69ab31 | | | 29 | case CommitPreview.HIDDEN_DESCENDANT: |
| b69ab31 | | | 30 | return false; |
| b69ab31 | | | 31 | |
| b69ab31 | | | 32 | // you CAN let go of the preview and drag it again |
| b69ab31 | | | 33 | case CommitPreview.REBASE_ROOT: |
| b69ab31 | | | 34 | // optimistic rebase commits act like normal, they can be dragged just fine |
| b69ab31 | | | 35 | case CommitPreview.REBASE_OPTIMISTIC_DESCENDANT: |
| b69ab31 | | | 36 | case CommitPreview.REBASE_OPTIMISTIC_ROOT: |
| b69ab31 | | | 37 | case undefined: |
| b69ab31 | | | 38 | // other unrelated previews are draggable |
| b69ab31 | | | 39 | default: |
| b69ab31 | | | 40 | return true; |
| b69ab31 | | | 41 | } |
| b69ab31 | | | 42 | } |
| b69ab31 | | | 43 | |
| b69ab31 | | | 44 | let commitBeingDragged: {info: CommitInfo; originalParents: ReadonlyArray<string>} | undefined = |
| b69ab31 | | | 45 | undefined; |
| b69ab31 | | | 46 | |
| b69ab31 | | | 47 | // This is a global state outside React because commit DnD is a global |
| b69ab31 | | | 48 | // concept: there won't be 2 DnD happening at once in the same window. |
| b69ab31 | | | 49 | let lastDndId = 0; |
| b69ab31 | | | 50 | |
| b69ab31 | | | 51 | function preventDefault(e: Event) { |
| b69ab31 | | | 52 | e.preventDefault(); |
| b69ab31 | | | 53 | } |
| b69ab31 | | | 54 | function handleDragEnd(event: Event) { |
| b69ab31 | | | 55 | event.preventDefault(); |
| b69ab31 | | | 56 | |
| b69ab31 | | | 57 | commitBeingDragged = undefined; |
| b69ab31 | | | 58 | const draggedDOMNode = event.target; |
| b69ab31 | | | 59 | draggedDOMNode?.removeEventListener('dragend', handleDragEnd); |
| b69ab31 | | | 60 | document.removeEventListener('drop', preventDefault); |
| b69ab31 | | | 61 | document.removeEventListener('dragover', preventDefault); |
| b69ab31 | | | 62 | } |
| b69ab31 | | | 63 | |
| b69ab31 | | | 64 | export function DragToRebase({ |
| b69ab31 | | | 65 | commit, |
| b69ab31 | | | 66 | previewType, |
| b69ab31 | | | 67 | children, |
| b69ab31 | | | 68 | className, |
| b69ab31 | | | 69 | onClick, |
| b69ab31 | | | 70 | onDoubleClick, |
| b69ab31 | | | 71 | onContextMenu, |
| b69ab31 | | | 72 | }: { |
| b69ab31 | | | 73 | commit: CommitInfo; |
| b69ab31 | | | 74 | previewType: CommitPreview | undefined; |
| b69ab31 | | | 75 | children: React.ReactNode; |
| b69ab31 | | | 76 | className: string; |
| b69ab31 | | | 77 | onClick?: (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => unknown; |
| b69ab31 | | | 78 | onDoubleClick?: (e: React.MouseEvent<HTMLDivElement>) => unknown; |
| b69ab31 | | | 79 | onContextMenu?: React.MouseEventHandler<HTMLDivElement>; |
| b69ab31 | | | 80 | }) { |
| b69ab31 | | | 81 | const draggable = commit.phase !== 'public' && isDraggablePreview(previewType); |
| b69ab31 | | | 82 | const [dragDisabledMessage, setDragDisabledMessage] = useState<string | null>(null); |
| b69ab31 | | | 83 | const handleDragEnter = useCallback(() => { |
| b69ab31 | | | 84 | // Capture the environment. |
| b69ab31 | | | 85 | const currentBeingDragged = commitBeingDragged; |
| b69ab31 | | | 86 | const currentDndId = ++lastDndId; |
| b69ab31 | | | 87 | |
| b69ab31 | | | 88 | const handleDnd = () => { |
| b69ab31 | | | 89 | // Skip handling if there was a new "DragEnter" event that invalidates this one. |
| b69ab31 | | | 90 | if (lastDndId != currentDndId) { |
| b69ab31 | | | 91 | return; |
| b69ab31 | | | 92 | } |
| b69ab31 | | | 93 | const dag = readAtom(latestDag); |
| b69ab31 | | | 94 | |
| b69ab31 | | | 95 | if (currentBeingDragged != null && commit.hash !== currentBeingDragged.info.hash) { |
| b69ab31 | | | 96 | const beingDragged = currentBeingDragged.info; |
| b69ab31 | | | 97 | const originalParents = currentBeingDragged.originalParents; |
| b69ab31 | | | 98 | if (dag.has(beingDragged.hash)) { |
| b69ab31 | | | 99 | if ( |
| b69ab31 | | | 100 | // can't rebase a commit onto its descendants |
| b69ab31 | | | 101 | !dag.isAncestor(beingDragged.hash, commit.hash) && |
| b69ab31 | | | 102 | // can't rebase a commit onto its parent... it's already there! |
| b69ab31 | | | 103 | (originalParents == null || !originalParents?.includes(commit.hash)) |
| b69ab31 | | | 104 | ) { |
| b69ab31 | | | 105 | // if the dest commit has a remote bookmark, use that instead of the hash. |
| b69ab31 | | | 106 | // this is easier to understand in the command history and works better with optimistic state |
| b69ab31 | | | 107 | const destination = |
| b69ab31 | | | 108 | commit.remoteBookmarks.length > 0 |
| b69ab31 | | | 109 | ? succeedableRevset(commit.remoteBookmarks[0]) |
| b69ab31 | | | 110 | : latestSuccessorUnlessExplicitlyObsolete(commit); |
| b69ab31 | | | 111 | writeAtom(operationBeingPreviewed, op => { |
| b69ab31 | | | 112 | const newRebase = new RebaseOperation( |
| b69ab31 | | | 113 | latestSuccessorUnlessExplicitlyObsolete(beingDragged), |
| b69ab31 | | | 114 | destination, |
| b69ab31 | | | 115 | ); |
| b69ab31 | | | 116 | const isEqual = newRebase.equals(op); |
| b69ab31 | | | 117 | return isEqual ? op : newRebase; |
| b69ab31 | | | 118 | }); |
| b69ab31 | | | 119 | } |
| b69ab31 | | | 120 | } |
| b69ab31 | | | 121 | } |
| b69ab31 | | | 122 | }; |
| b69ab31 | | | 123 | |
| b69ab31 | | | 124 | // This allows us to receive a list of "queued" DragEnter events |
| b69ab31 | | | 125 | // before actually handling them. This way we can skip "invalidated" |
| b69ab31 | | | 126 | // events and only handle the last (valid) one. |
| b69ab31 | | | 127 | window.setTimeout(() => { |
| b69ab31 | | | 128 | handleDnd(); |
| b69ab31 | | | 129 | }, 1); |
| b69ab31 | | | 130 | }, [commit]); |
| b69ab31 | | | 131 | |
| b69ab31 | | | 132 | const handleDragStart = useCallback( |
| b69ab31 | | | 133 | (event: React.DragEvent<HTMLDivElement>) => { |
| b69ab31 | | | 134 | // can't rebase with uncommitted changes |
| b69ab31 | | | 135 | if (hasUncommittedChanges()) { |
| b69ab31 | | | 136 | setDragDisabledMessage(t('Cannot drag to rebase with uncommitted changes.')); |
| b69ab31 | | | 137 | event.preventDefault(); |
| b69ab31 | | | 138 | } |
| b69ab31 | | | 139 | if (commit.successorInfo != null) { |
| b69ab31 | | | 140 | setDragDisabledMessage(t('Cannot rebase obsoleted commits.')); |
| b69ab31 | | | 141 | event.preventDefault(); |
| b69ab31 | | | 142 | } |
| b69ab31 | | | 143 | |
| b69ab31 | | | 144 | // To avoid rebasing onto the original parent commit, we save the parent. |
| b69ab31 | | | 145 | // If you've already dragging, we need to lookup the old rebase preview commit in the dag. |
| b69ab31 | | | 146 | // This depends on the RebaseOperation's REBASE_OLD preview. |
| b69ab31 | | | 147 | const dag = readAtom(dagWithPreviews); |
| b69ab31 | | | 148 | const oldRebaseCommitHash = `${REBASE_PREVIEW_HASH_PREFIX}:${commit.parents[0]}:${commit.hash}`; |
| b69ab31 | | | 149 | const original = dag.get(oldRebaseCommitHash) ?? commit; |
| b69ab31 | | | 150 | |
| b69ab31 | | | 151 | commitBeingDragged = {info: commit, originalParents: original.parents}; |
| b69ab31 | | | 152 | event.dataTransfer.dropEffect = 'none'; |
| b69ab31 | | | 153 | |
| b69ab31 | | | 154 | const draggedDOMNode = event.target; |
| b69ab31 | | | 155 | // prevent animation of commit returning to drag start location on drop |
| b69ab31 | | | 156 | draggedDOMNode.addEventListener('dragend', handleDragEnd); |
| b69ab31 | | | 157 | document.addEventListener('drop', preventDefault); |
| b69ab31 | | | 158 | document.addEventListener('dragover', preventDefault); |
| b69ab31 | | | 159 | }, |
| b69ab31 | | | 160 | [commit], |
| b69ab31 | | | 161 | ); |
| b69ab31 | | | 162 | |
| b69ab31 | | | 163 | useEffect(() => { |
| b69ab31 | | | 164 | if (dragDisabledMessage) { |
| b69ab31 | | | 165 | const timeout = setTimeout(() => setDragDisabledMessage(null), 1500); |
| b69ab31 | | | 166 | return () => clearTimeout(timeout); |
| b69ab31 | | | 167 | } |
| b69ab31 | | | 168 | }, [dragDisabledMessage]); |
| b69ab31 | | | 169 | |
| b69ab31 | | | 170 | return ( |
| b69ab31 | | | 171 | <div |
| b69ab31 | | | 172 | className={className} |
| b69ab31 | | | 173 | onDragStart={handleDragStart} |
| b69ab31 | | | 174 | onDragEnter={handleDragEnter} |
| b69ab31 | | | 175 | draggable={draggable} |
| b69ab31 | | | 176 | onClick={onClick} |
| b69ab31 | | | 177 | onDoubleClick={onDoubleClick} |
| b69ab31 | | | 178 | onKeyPress={event => { |
| b69ab31 | | | 179 | if (event.key === 'Enter') { |
| b69ab31 | | | 180 | onClick?.(event); |
| b69ab31 | | | 181 | } |
| b69ab31 | | | 182 | }} |
| b69ab31 | | | 183 | onContextMenu={onContextMenu} |
| b69ab31 | | | 184 | data-testid={'draggable-commit'}> |
| b69ab31 | | | 185 | <div className="commit-wide-drag-target" onDragEnter={handleDragEnter} /> |
| b69ab31 | | | 186 | {dragDisabledMessage != null ? ( |
| b69ab31 | | | 187 | <Tooltip trigger="manual" shouldShow title={dragDisabledMessage}> |
| b69ab31 | | | 188 | {children} |
| b69ab31 | | | 189 | </Tooltip> |
| b69ab31 | | | 190 | ) : ( |
| b69ab31 | | | 191 | children |
| b69ab31 | | | 192 | )} |
| b69ab31 | | | 193 | </div> |
| b69ab31 | | | 194 | ); |
| b69ab31 | | | 195 | } |
| b69ab31 | | | 196 | |
| b69ab31 | | | 197 | function hasUncommittedChanges(): boolean { |
| b69ab31 | | | 198 | const changes = readAtom(uncommittedChangesWithPreviews); |
| b69ab31 | | | 199 | return ( |
| b69ab31 | | | 200 | changes.filter( |
| b69ab31 | | | 201 | commit => commit.status !== '?', // untracked files are ok |
| b69ab31 | | | 202 | ).length > 0 |
| b69ab31 | | | 203 | ); |
| b69ab31 | | | 204 | } |