12.5 KB380 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 React from 'react';
9import type {ISLCommandName} from './ISLShortcuts';
10
11import {isMac} from 'isl-components/OperatingSystem';
12import {atom} from 'jotai';
13import {useCallback} from 'react';
14import {commitMode} from './CommitInfoView/CommitInfoState';
15import {useCommand} from './ISLShortcuts';
16import {useSelectAllCommitsShortcut} from './SelectAllCommits';
17import {successionTracker} from './SuccessionTracker';
18import {YOU_ARE_HERE_VIRTUAL_COMMIT} from './dag/virtualCommit';
19import {islDrawerState} from './drawerState';
20import {findPublicBaseAncestor} from './getCommitTree';
21import {t} from './i18n';
22import {readAtom, useAtomHas, writeAtom} from './jotaiUtils';
23import {BulkRebaseOperation} from './operations/BulkRebaseOperation';
24import {HideOperation} from './operations/HideOperation';
25import {RebaseOperation} from './operations/RebaseOperation';
26import {operationBeingPreviewed, useRunOperation} from './operationsState';
27import platform from './platform';
28import {dagWithPreviews} from './previews';
29import {latestDag} from './serverAPIState';
30import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
31import {exactRevset, type CommitInfo, type Hash} from './types';
32import {firstOfIterable, registerCleanup} from './utils';
33
34/**
35 * The name of the key to toggle individual selection.
36 * On Windows / Linux, it is Ctrl. On Mac, it is Command.
37 */
38export const individualToggleKey: 'metaKey' | 'ctrlKey' = isMac ? 'metaKey' : 'ctrlKey';
39
40/**
41 * See {@link selectedCommitInfos}
42 * Note: it is possible to be selecting a commit that stops being rendered, and thus has no associated commit info.
43 * Prefer to use `selectedCommitInfos` to get the subset of the selection that is visible.
44 */
45export const selectedCommits = atom(new Set<Hash>());
46
47/**
48 * Commit that is currently being actioned on (i.e., from the context menu).
49 * This is a temporary visual state separate from selection.
50 */
51export const actioningCommit = atom<Hash | null>(null);
52registerCleanup(
53 selectedCommits,
54 successionTracker.onSuccessions(successions => {
55 let value = readAtom(selectedCommits);
56 let changed = false;
57
58 for (const [oldHash, newHash] of successions) {
59 if (value?.has(oldHash)) {
60 if (!changed) {
61 changed = true;
62 value = new Set(value);
63 }
64 value.delete(oldHash);
65 value.add(newHash);
66 }
67 }
68 if (changed) {
69 writeAtom(selectedCommits, value);
70 }
71 }),
72 import.meta.hot,
73);
74
75const previouslySelectedCommit = atom<undefined | string>(undefined);
76
77/**
78 * Clicking on commits will select them in the UI.
79 * Selected commits can be acted on in bulk, and appear in the commit info sidebar for editing / details.
80 * Invariant: Selected commits are non-public.
81 *
82 * See {@link selectedCommits} for setting underlying storage
83 */
84export const selectedCommitInfos = atom(get => {
85 const selected = get(selectedCommits);
86 const dag = get(dagWithPreviews);
87 return [...selected].flatMap(h => {
88 const info = dag.get(h);
89 return info === undefined ? [] : [info];
90 });
91});
92
93export function useCommitSelection(hash: string): {
94 isSelected: boolean;
95 onClickToSelect: (
96 _e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
97 ) => unknown;
98 overrideSelection: (newSelected: Array<Hash>) => void;
99} {
100 const isSelected = useAtomHas(selectedCommits, hash);
101 const onClickToSelect = useCallback(
102 (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
103 // previews won't change a commit from draft -> public, so we don't need
104 // to use previews here
105 const dag = readAtom(latestDag);
106 if (hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash) {
107 // don't bother selecting virtual commits
108 return;
109 }
110 writeAtom(selectedCommits, last => {
111 if (e.shiftKey) {
112 const previouslySelected = readAtom(previouslySelectedCommit);
113 if (previouslySelected != null) {
114 let slice: Array<Hash> | null = null;
115 const dag = readAtom(dagWithPreviews);
116 // Prefer dag range for shift selection.
117 const range = dag
118 .range(hash, previouslySelected)
119 .union(dag.range(previouslySelected, hash));
120 if (range.size > 0) {
121 slice = range.toArray();
122 } else {
123 // Fall back to displayed (flatten) range.
124 const [sortIndex, sorted] = dag.defaultSortAscIndex();
125 const prevIdx = sortIndex.get(previouslySelected);
126 const nextIdx = sortIndex.get(hash);
127 if (prevIdx != null && nextIdx != null) {
128 const [fromIdx, toIdx] =
129 prevIdx > nextIdx ? [nextIdx, prevIdx] : [prevIdx, nextIdx];
130 slice = sorted.slice(fromIdx, toIdx + 1);
131 }
132 }
133 if (slice != null) {
134 return new Set([...last, ...slice]);
135 }
136 }
137 // Holding shift, but we don't have a previous selected commit.
138 // Fall through to treat it like a normal click.
139 }
140
141 const individualToggle = e[individualToggleKey];
142
143 const selected = new Set(last);
144 if (selected.has(hash)) {
145 // multiple selected, then click an existing selected:
146 // if cmd, unselect just that one commit
147 // if not cmd, reset selection to just that one commit
148 // only one selected, then click on it
149 // if cmd, unselect it
150 // it not cmd, unselect it
151 if (!individualToggle && selected.size > 1) {
152 // only select this commit
153 selected.clear();
154 selected.add(hash);
155 } else {
156 // unselect
157 selected.delete(hash);
158 writeAtom(previouslySelectedCommit, undefined);
159 }
160 } else {
161 if (!individualToggle) {
162 // clear if not holding cmd key
163 selected.clear();
164 }
165 selected.add(hash);
166 }
167 return selected;
168 });
169 writeAtom(previouslySelectedCommit, hash);
170 },
171 [hash],
172 );
173
174 const overrideSelection = useCallback(
175 (newSelected: Array<Hash>) => {
176 // previews won't change a commit from draft -> public, so we don't need
177 // to use previews here
178 const dag = readAtom(latestDag);
179 writeAtom(selectedCommits, new Set(newSelected));
180 },
181 [hash],
182 );
183
184 return {isSelected, onClickToSelect, overrideSelection};
185}
186
187/** A richer version of `useCommitSelection`, provides extra handlers like `onDoubleClickToShowDrawer`. */
188export function useCommitCallbacks(commit: CommitInfo): {
189 isSelected: boolean;
190 onClickToSelect: (
191 _e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
192 ) => unknown;
193 onDoubleClickToShowDrawer: () => void;
194} {
195 const {isSelected, onClickToSelect, overrideSelection} = useCommitSelection(commit.hash);
196 const onDoubleClickToShowDrawer = useCallback(() => {
197 // Select the commit if it was deselected.
198 if (!isSelected) {
199 if (commit.hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash) {
200 // don't select virtual commit, replace selection instead
201 overrideSelection([]);
202 } else {
203 overrideSelection([commit.hash]);
204 }
205 }
206 // Show the drawer.
207 writeAtom(islDrawerState, state => ({
208 ...state,
209 right: {
210 ...state.right,
211 collapsed: false,
212 },
213 }));
214 if (commit.isDot) {
215 // if we happened to be in commit mode, swap to amend mode so you see the details instead
216 writeAtom(commitMode, 'amend');
217 }
218 }, [overrideSelection, isSelected, commit.hash, commit.isDot]);
219 return {isSelected, onClickToSelect, onDoubleClickToShowDrawer};
220}
221
222export function useArrowKeysToChangeSelection() {
223 const cb = useCallback((which: ISLCommandName) => {
224 if (which === 'OpenDetails') {
225 writeAtom(islDrawerState, previous => ({
226 ...previous,
227 right: {
228 ...previous.right,
229 collapsed: false,
230 },
231 }));
232 }
233
234 const dag = readAtom(dagWithPreviews);
235 const [sortIndex, sorted] = dag.defaultSortAscIndex();
236
237 if (sorted.length === 0) {
238 return;
239 }
240
241 const lastSelected = readAtom(previouslySelectedCommit);
242 const lastIndex = lastSelected == null ? undefined : sortIndex.get(lastSelected);
243
244 const nextSelectableHash = (step = 1 /* 1: up; -1: down */, start = lastIndex ?? 0) => {
245 let index = start;
246 while (index > 0) {
247 index += step;
248 const hash = sorted.at(index);
249 if (hash == null) {
250 return undefined;
251 }
252 return hash;
253 }
254 };
255
256 const existingSelection = readAtom(selectedCommits);
257 if (existingSelection.size === 0) {
258 if (which === 'SelectDownwards' || which === 'ContinueSelectionDownwards') {
259 const top = nextSelectableHash(-1, sorted.length);
260 if (top != null) {
261 writeAtom(selectedCommits, new Set([top]));
262 writeAtom(previouslySelectedCommit, top);
263 }
264 }
265 return;
266 }
267
268 if (lastSelected == null || lastIndex == null) {
269 return;
270 }
271
272 let newSelected: Hash | undefined;
273 let extendSelection = false;
274
275 switch (which) {
276 case 'SelectUpwards': {
277 newSelected = nextSelectableHash(1);
278 break;
279 }
280 case 'SelectDownwards': {
281 newSelected = nextSelectableHash(-1);
282 break;
283 }
284 case 'ContinueSelectionUpwards': {
285 newSelected = nextSelectableHash(1);
286 extendSelection = true;
287 break;
288 }
289 case 'ContinueSelectionDownwards': {
290 newSelected = nextSelectableHash(-1);
291 extendSelection = true;
292 break;
293 }
294 }
295
296 if (newSelected != null) {
297 const newHash = newSelected;
298 writeAtom(selectedCommits, last =>
299 extendSelection ? new Set([...last, newHash]) : new Set([newHash]),
300 );
301 writeAtom(previouslySelectedCommit, newHash);
302 }
303 }, []);
304
305 useCommand('OpenDetails', () => cb('OpenDetails'));
306 useCommand('SelectUpwards', () => cb('SelectUpwards'));
307 useCommand('SelectDownwards', () => cb('SelectDownwards'));
308 useCommand('ContinueSelectionUpwards', () => cb('ContinueSelectionUpwards'));
309 useCommand('ContinueSelectionDownwards', () => cb('ContinueSelectionDownwards'));
310 useSelectAllCommitsShortcut();
311}
312
313export function useBackspaceToHideSelected(): void {
314 const cb = useCallback(() => {
315 // Though you can select multiple commits, our preview system doesn't handle that very well.
316 // Just preview hiding the most recently selected commit.
317 // Another sensible behavior would be to inspect the tree of commits selected
318 // and find if there's a single common ancestor to hide. That won't work in all cases though.
319 const mostRecent = readAtom(previouslySelectedCommit);
320 let hashToHide = mostRecent;
321 if (hashToHide == null) {
322 const selection = readAtom(selectedCommits);
323 if (selection != null) {
324 hashToHide = firstOfIterable(selection.values());
325 }
326 }
327 if (hashToHide == null) {
328 return;
329 }
330
331 const commitToHide = readAtom(latestDag).get(hashToHide);
332 if (commitToHide == null) {
333 return;
334 }
335
336 writeAtom(
337 operationBeingPreviewed,
338 new HideOperation(latestSuccessorUnlessExplicitlyObsolete(commitToHide)),
339 );
340 }, []);
341
342 useCommand('HideSelectedCommits', cb);
343}
344
345export function useShortcutToRebaseSelected(): void {
346 const runOperation = useRunOperation();
347
348 const cb = useCallback(async () => {
349 const dag = readAtom(dagWithPreviews);
350 const baseCommit = findPublicBaseAncestor(dag);
351 if (!baseCommit) {
352 return;
353 }
354 const baseCommitRevset = exactRevset(baseCommit.hash);
355
356 const selectedCommits = readAtom(selectedCommitInfos);
357 const selectedRevsets = selectedCommits
358 .filter(commitInfo => findPublicBaseAncestor(dag, commitInfo.hash)?.hash !== baseCommit.hash)
359 .map(latestSuccessorUnlessExplicitlyObsolete);
360
361 if (selectedRevsets.length === 0) {
362 return;
363 } else if (selectedRevsets.length === 1) {
364 writeAtom(
365 operationBeingPreviewed,
366 () => new RebaseOperation(selectedRevsets[0], baseCommitRevset),
367 );
368 } else {
369 if (
370 await platform.confirm(
371 t('Are you sure you want to rebase $count commits?', {count: selectedRevsets.length}),
372 )
373 ) {
374 runOperation(new BulkRebaseOperation(selectedRevsets, baseCommitRevset));
375 }
376 }
377 }, [runOperation]);
378 useCommand('RebaseOntoCurrentStackBase', cb);
379}
380