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