addons/isl/src/DragToRebase.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {CommitInfo} from './types';
b69ab319
b69ab3110import {Tooltip} from 'isl-components/Tooltip';
b69ab3111import {useCallback, useEffect, useState} from 'react';
b69ab3112import {t} from './i18n';
b69ab3113import {readAtom, writeAtom} from './jotaiUtils';
b69ab3114import {REBASE_PREVIEW_HASH_PREFIX, RebaseOperation} from './operations/RebaseOperation';
b69ab3115import {operationBeingPreviewed} from './operationsState';
b69ab3116import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from './previews';
b69ab3117import {latestDag} from './serverAPIState';
b69ab3118import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
b69ab3119import {succeedableRevset} from './types';
b69ab3120
b69ab3121function isDraggablePreview(previewType?: CommitPreview): boolean {
b69ab3122 switch (previewType) {
b69ab3123 // dragging preview descendants would be confusing (it would reset part of your drag),
b69ab3124 // you probably meant to drag the root.
b69ab3125 case CommitPreview.REBASE_DESCENDANT:
b69ab3126 // old commits are already being dragged
b69ab3127 case CommitPreview.REBASE_OLD:
b69ab3128 case CommitPreview.HIDDEN_ROOT:
b69ab3129 case CommitPreview.HIDDEN_DESCENDANT:
b69ab3130 return false;
b69ab3131
b69ab3132 // you CAN let go of the preview and drag it again
b69ab3133 case CommitPreview.REBASE_ROOT:
b69ab3134 // optimistic rebase commits act like normal, they can be dragged just fine
b69ab3135 case CommitPreview.REBASE_OPTIMISTIC_DESCENDANT:
b69ab3136 case CommitPreview.REBASE_OPTIMISTIC_ROOT:
b69ab3137 case undefined:
b69ab3138 // other unrelated previews are draggable
b69ab3139 default:
b69ab3140 return true;
b69ab3141 }
b69ab3142}
b69ab3143
b69ab3144let commitBeingDragged: {info: CommitInfo; originalParents: ReadonlyArray<string>} | undefined =
b69ab3145 undefined;
b69ab3146
b69ab3147// This is a global state outside React because commit DnD is a global
b69ab3148// concept: there won't be 2 DnD happening at once in the same window.
b69ab3149let lastDndId = 0;
b69ab3150
b69ab3151function preventDefault(e: Event) {
b69ab3152 e.preventDefault();
b69ab3153}
b69ab3154function handleDragEnd(event: Event) {
b69ab3155 event.preventDefault();
b69ab3156
b69ab3157 commitBeingDragged = undefined;
b69ab3158 const draggedDOMNode = event.target;
b69ab3159 draggedDOMNode?.removeEventListener('dragend', handleDragEnd);
b69ab3160 document.removeEventListener('drop', preventDefault);
b69ab3161 document.removeEventListener('dragover', preventDefault);
b69ab3162}
b69ab3163
b69ab3164export function DragToRebase({
b69ab3165 commit,
b69ab3166 previewType,
b69ab3167 children,
b69ab3168 className,
b69ab3169 onClick,
b69ab3170 onDoubleClick,
b69ab3171 onContextMenu,
b69ab3172}: {
b69ab3173 commit: CommitInfo;
b69ab3174 previewType: CommitPreview | undefined;
b69ab3175 children: React.ReactNode;
b69ab3176 className: string;
b69ab3177 onClick?: (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => unknown;
b69ab3178 onDoubleClick?: (e: React.MouseEvent<HTMLDivElement>) => unknown;
b69ab3179 onContextMenu?: React.MouseEventHandler<HTMLDivElement>;
b69ab3180}) {
b69ab3181 const draggable = commit.phase !== 'public' && isDraggablePreview(previewType);
b69ab3182 const [dragDisabledMessage, setDragDisabledMessage] = useState<string | null>(null);
b69ab3183 const handleDragEnter = useCallback(() => {
b69ab3184 // Capture the environment.
b69ab3185 const currentBeingDragged = commitBeingDragged;
b69ab3186 const currentDndId = ++lastDndId;
b69ab3187
b69ab3188 const handleDnd = () => {
b69ab3189 // Skip handling if there was a new "DragEnter" event that invalidates this one.
b69ab3190 if (lastDndId != currentDndId) {
b69ab3191 return;
b69ab3192 }
b69ab3193 const dag = readAtom(latestDag);
b69ab3194
b69ab3195 if (currentBeingDragged != null && commit.hash !== currentBeingDragged.info.hash) {
b69ab3196 const beingDragged = currentBeingDragged.info;
b69ab3197 const originalParents = currentBeingDragged.originalParents;
b69ab3198 if (dag.has(beingDragged.hash)) {
b69ab3199 if (
b69ab31100 // can't rebase a commit onto its descendants
b69ab31101 !dag.isAncestor(beingDragged.hash, commit.hash) &&
b69ab31102 // can't rebase a commit onto its parent... it's already there!
b69ab31103 (originalParents == null || !originalParents?.includes(commit.hash))
b69ab31104 ) {
b69ab31105 // if the dest commit has a remote bookmark, use that instead of the hash.
b69ab31106 // this is easier to understand in the command history and works better with optimistic state
b69ab31107 const destination =
b69ab31108 commit.remoteBookmarks.length > 0
b69ab31109 ? succeedableRevset(commit.remoteBookmarks[0])
b69ab31110 : latestSuccessorUnlessExplicitlyObsolete(commit);
b69ab31111 writeAtom(operationBeingPreviewed, op => {
b69ab31112 const newRebase = new RebaseOperation(
b69ab31113 latestSuccessorUnlessExplicitlyObsolete(beingDragged),
b69ab31114 destination,
b69ab31115 );
b69ab31116 const isEqual = newRebase.equals(op);
b69ab31117 return isEqual ? op : newRebase;
b69ab31118 });
b69ab31119 }
b69ab31120 }
b69ab31121 }
b69ab31122 };
b69ab31123
b69ab31124 // This allows us to receive a list of "queued" DragEnter events
b69ab31125 // before actually handling them. This way we can skip "invalidated"
b69ab31126 // events and only handle the last (valid) one.
b69ab31127 window.setTimeout(() => {
b69ab31128 handleDnd();
b69ab31129 }, 1);
b69ab31130 }, [commit]);
b69ab31131
b69ab31132 const handleDragStart = useCallback(
b69ab31133 (event: React.DragEvent<HTMLDivElement>) => {
b69ab31134 // can't rebase with uncommitted changes
b69ab31135 if (hasUncommittedChanges()) {
b69ab31136 setDragDisabledMessage(t('Cannot drag to rebase with uncommitted changes.'));
b69ab31137 event.preventDefault();
b69ab31138 }
b69ab31139 if (commit.successorInfo != null) {
b69ab31140 setDragDisabledMessage(t('Cannot rebase obsoleted commits.'));
b69ab31141 event.preventDefault();
b69ab31142 }
b69ab31143
b69ab31144 // To avoid rebasing onto the original parent commit, we save the parent.
b69ab31145 // If you've already dragging, we need to lookup the old rebase preview commit in the dag.
b69ab31146 // This depends on the RebaseOperation's REBASE_OLD preview.
b69ab31147 const dag = readAtom(dagWithPreviews);
b69ab31148 const oldRebaseCommitHash = `${REBASE_PREVIEW_HASH_PREFIX}:${commit.parents[0]}:${commit.hash}`;
b69ab31149 const original = dag.get(oldRebaseCommitHash) ?? commit;
b69ab31150
b69ab31151 commitBeingDragged = {info: commit, originalParents: original.parents};
b69ab31152 event.dataTransfer.dropEffect = 'none';
b69ab31153
b69ab31154 const draggedDOMNode = event.target;
b69ab31155 // prevent animation of commit returning to drag start location on drop
b69ab31156 draggedDOMNode.addEventListener('dragend', handleDragEnd);
b69ab31157 document.addEventListener('drop', preventDefault);
b69ab31158 document.addEventListener('dragover', preventDefault);
b69ab31159 },
b69ab31160 [commit],
b69ab31161 );
b69ab31162
b69ab31163 useEffect(() => {
b69ab31164 if (dragDisabledMessage) {
b69ab31165 const timeout = setTimeout(() => setDragDisabledMessage(null), 1500);
b69ab31166 return () => clearTimeout(timeout);
b69ab31167 }
b69ab31168 }, [dragDisabledMessage]);
b69ab31169
b69ab31170 return (
b69ab31171 <div
b69ab31172 className={className}
b69ab31173 onDragStart={handleDragStart}
b69ab31174 onDragEnter={handleDragEnter}
b69ab31175 draggable={draggable}
b69ab31176 onClick={onClick}
b69ab31177 onDoubleClick={onDoubleClick}
b69ab31178 onKeyPress={event => {
b69ab31179 if (event.key === 'Enter') {
b69ab31180 onClick?.(event);
b69ab31181 }
b69ab31182 }}
b69ab31183 onContextMenu={onContextMenu}
b69ab31184 data-testid={'draggable-commit'}>
b69ab31185 <div className="commit-wide-drag-target" onDragEnter={handleDragEnter} />
b69ab31186 {dragDisabledMessage != null ? (
b69ab31187 <Tooltip trigger="manual" shouldShow title={dragDisabledMessage}>
b69ab31188 {children}
b69ab31189 </Tooltip>
b69ab31190 ) : (
b69ab31191 children
b69ab31192 )}
b69ab31193 </div>
b69ab31194 );
b69ab31195}
b69ab31196
b69ab31197function hasUncommittedChanges(): boolean {
b69ab31198 const changes = readAtom(uncommittedChangesWithPreviews);
b69ab31199 return (
b69ab31200 changes.filter(
b69ab31201 commit => commit.status !== '?', // untracked files are ok
b69ab31202 ).length > 0
b69ab31203 );
b69ab31204}