7.3 KB205 lines
Blame
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
8import type {CommitInfo} from './types';
9
10import {Tooltip} from 'isl-components/Tooltip';
11import {useCallback, useEffect, useState} from 'react';
12import {t} from './i18n';
13import {readAtom, writeAtom} from './jotaiUtils';
14import {REBASE_PREVIEW_HASH_PREFIX, RebaseOperation} from './operations/RebaseOperation';
15import {operationBeingPreviewed} from './operationsState';
16import {CommitPreview, dagWithPreviews, uncommittedChangesWithPreviews} from './previews';
17import {latestDag} from './serverAPIState';
18import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
19import {succeedableRevset} from './types';
20
21function 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
44let 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.
49let lastDndId = 0;
50
51function preventDefault(e: Event) {
52 e.preventDefault();
53}
54function 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
64export 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
197function 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