addons/isl/src/selection.tsblame
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 React from 'react';
b69ab319import type {ISLCommandName} from './ISLShortcuts';
b69ab3110
b69ab3111import {isMac} from 'isl-components/OperatingSystem';
b69ab3112import {atom} from 'jotai';
b69ab3113import {useCallback} from 'react';
b69ab3114import {commitMode} from './CommitInfoView/CommitInfoState';
b69ab3115import {useCommand} from './ISLShortcuts';
b69ab3116import {useSelectAllCommitsShortcut} from './SelectAllCommits';
b69ab3117import {successionTracker} from './SuccessionTracker';
b69ab3118import {YOU_ARE_HERE_VIRTUAL_COMMIT} from './dag/virtualCommit';
b69ab3119import {islDrawerState} from './drawerState';
b69ab3120import {findPublicBaseAncestor} from './getCommitTree';
b69ab3121import {t} from './i18n';
b69ab3122import {readAtom, useAtomHas, writeAtom} from './jotaiUtils';
b69ab3123import {BulkRebaseOperation} from './operations/BulkRebaseOperation';
b69ab3124import {HideOperation} from './operations/HideOperation';
b69ab3125import {RebaseOperation} from './operations/RebaseOperation';
b69ab3126import {operationBeingPreviewed, useRunOperation} from './operationsState';
b69ab3127import platform from './platform';
b69ab3128import {dagWithPreviews} from './previews';
b69ab3129import {latestDag} from './serverAPIState';
b69ab3130import {latestSuccessorUnlessExplicitlyObsolete} from './successionUtils';
b69ab3131import {exactRevset, type CommitInfo, type Hash} from './types';
b69ab3132import {firstOfIterable, registerCleanup} from './utils';
b69ab3133
b69ab3134/**
b69ab3135 * The name of the key to toggle individual selection.
b69ab3136 * On Windows / Linux, it is Ctrl. On Mac, it is Command.
b69ab3137 */
b69ab3138export const individualToggleKey: 'metaKey' | 'ctrlKey' = isMac ? 'metaKey' : 'ctrlKey';
b69ab3139
b69ab3140/**
b69ab3141 * See {@link selectedCommitInfos}
b69ab3142 * Note: it is possible to be selecting a commit that stops being rendered, and thus has no associated commit info.
b69ab3143 * Prefer to use `selectedCommitInfos` to get the subset of the selection that is visible.
b69ab3144 */
b69ab3145export const selectedCommits = atom(new Set<Hash>());
b69ab3146
b69ab3147/**
b69ab3148 * Commit that is currently being actioned on (i.e., from the context menu).
b69ab3149 * This is a temporary visual state separate from selection.
b69ab3150 */
b69ab3151export const actioningCommit = atom<Hash | null>(null);
b69ab3152registerCleanup(
b69ab3153 selectedCommits,
b69ab3154 successionTracker.onSuccessions(successions => {
b69ab3155 let value = readAtom(selectedCommits);
b69ab3156 let changed = false;
b69ab3157
b69ab3158 for (const [oldHash, newHash] of successions) {
b69ab3159 if (value?.has(oldHash)) {
b69ab3160 if (!changed) {
b69ab3161 changed = true;
b69ab3162 value = new Set(value);
b69ab3163 }
b69ab3164 value.delete(oldHash);
b69ab3165 value.add(newHash);
b69ab3166 }
b69ab3167 }
b69ab3168 if (changed) {
b69ab3169 writeAtom(selectedCommits, value);
b69ab3170 }
b69ab3171 }),
b69ab3172 import.meta.hot,
b69ab3173);
b69ab3174
b69ab3175const previouslySelectedCommit = atom<undefined | string>(undefined);
b69ab3176
b69ab3177/**
b69ab3178 * Clicking on commits will select them in the UI.
b69ab3179 * Selected commits can be acted on in bulk, and appear in the commit info sidebar for editing / details.
b69ab3180 * Invariant: Selected commits are non-public.
b69ab3181 *
b69ab3182 * See {@link selectedCommits} for setting underlying storage
b69ab3183 */
b69ab3184export const selectedCommitInfos = atom(get => {
b69ab3185 const selected = get(selectedCommits);
b69ab3186 const dag = get(dagWithPreviews);
b69ab3187 return [...selected].flatMap(h => {
b69ab3188 const info = dag.get(h);
b69ab3189 return info === undefined ? [] : [info];
b69ab3190 });
b69ab3191});
b69ab3192
b69ab3193export function useCommitSelection(hash: string): {
b69ab3194 isSelected: boolean;
b69ab3195 onClickToSelect: (
b69ab3196 _e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
b69ab3197 ) => unknown;
b69ab3198 overrideSelection: (newSelected: Array<Hash>) => void;
b69ab3199} {
b69ab31100 const isSelected = useAtomHas(selectedCommits, hash);
b69ab31101 const onClickToSelect = useCallback(
b69ab31102 (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => {
b69ab31103 // previews won't change a commit from draft -> public, so we don't need
b69ab31104 // to use previews here
b69ab31105 const dag = readAtom(latestDag);
ab83ad3106 if (hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash) {
ab83ad3107 // don't bother selecting virtual commits
b69ab31108 return;
b69ab31109 }
b69ab31110 writeAtom(selectedCommits, last => {
b69ab31111 if (e.shiftKey) {
b69ab31112 const previouslySelected = readAtom(previouslySelectedCommit);
b69ab31113 if (previouslySelected != null) {
b69ab31114 let slice: Array<Hash> | null = null;
b69ab31115 const dag = readAtom(dagWithPreviews);
b69ab31116 // Prefer dag range for shift selection.
b69ab31117 const range = dag
b69ab31118 .range(hash, previouslySelected)
b69ab31119 .union(dag.range(previouslySelected, hash));
b69ab31120 if (range.size > 0) {
b69ab31121 slice = range.toArray();
b69ab31122 } else {
b69ab31123 // Fall back to displayed (flatten) range.
b69ab31124 const [sortIndex, sorted] = dag.defaultSortAscIndex();
b69ab31125 const prevIdx = sortIndex.get(previouslySelected);
b69ab31126 const nextIdx = sortIndex.get(hash);
b69ab31127 if (prevIdx != null && nextIdx != null) {
b69ab31128 const [fromIdx, toIdx] =
b69ab31129 prevIdx > nextIdx ? [nextIdx, prevIdx] : [prevIdx, nextIdx];
b69ab31130 slice = sorted.slice(fromIdx, toIdx + 1);
b69ab31131 }
b69ab31132 }
b69ab31133 if (slice != null) {
ab83ad3134 return new Set([...last, ...slice]);
b69ab31135 }
b69ab31136 }
b69ab31137 // Holding shift, but we don't have a previous selected commit.
b69ab31138 // Fall through to treat it like a normal click.
b69ab31139 }
b69ab31140
b69ab31141 const individualToggle = e[individualToggleKey];
b69ab31142
b69ab31143 const selected = new Set(last);
b69ab31144 if (selected.has(hash)) {
b69ab31145 // multiple selected, then click an existing selected:
b69ab31146 // if cmd, unselect just that one commit
b69ab31147 // if not cmd, reset selection to just that one commit
b69ab31148 // only one selected, then click on it
b69ab31149 // if cmd, unselect it
b69ab31150 // it not cmd, unselect it
b69ab31151 if (!individualToggle && selected.size > 1) {
b69ab31152 // only select this commit
b69ab31153 selected.clear();
b69ab31154 selected.add(hash);
b69ab31155 } else {
b69ab31156 // unselect
b69ab31157 selected.delete(hash);
b69ab31158 writeAtom(previouslySelectedCommit, undefined);
b69ab31159 }
b69ab31160 } else {
b69ab31161 if (!individualToggle) {
b69ab31162 // clear if not holding cmd key
b69ab31163 selected.clear();
b69ab31164 }
b69ab31165 selected.add(hash);
b69ab31166 }
b69ab31167 return selected;
b69ab31168 });
b69ab31169 writeAtom(previouslySelectedCommit, hash);
b69ab31170 },
b69ab31171 [hash],
b69ab31172 );
b69ab31173
b69ab31174 const overrideSelection = useCallback(
b69ab31175 (newSelected: Array<Hash>) => {
b69ab31176 // previews won't change a commit from draft -> public, so we don't need
b69ab31177 // to use previews here
b69ab31178 const dag = readAtom(latestDag);
ab83ad3179 writeAtom(selectedCommits, new Set(newSelected));
b69ab31180 },
b69ab31181 [hash],
b69ab31182 );
b69ab31183
b69ab31184 return {isSelected, onClickToSelect, overrideSelection};
b69ab31185}
b69ab31186
b69ab31187/** A richer version of `useCommitSelection`, provides extra handlers like `onDoubleClickToShowDrawer`. */
b69ab31188export function useCommitCallbacks(commit: CommitInfo): {
b69ab31189 isSelected: boolean;
b69ab31190 onClickToSelect: (
b69ab31191 _e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
b69ab31192 ) => unknown;
b69ab31193 onDoubleClickToShowDrawer: () => void;
b69ab31194} {
b69ab31195 const {isSelected, onClickToSelect, overrideSelection} = useCommitSelection(commit.hash);
b69ab31196 const onDoubleClickToShowDrawer = useCallback(() => {
b69ab31197 // Select the commit if it was deselected.
b69ab31198 if (!isSelected) {
b69ab31199 if (commit.hash === YOU_ARE_HERE_VIRTUAL_COMMIT.hash) {
b69ab31200 // don't select virtual commit, replace selection instead
b69ab31201 overrideSelection([]);
b69ab31202 } else {
b69ab31203 overrideSelection([commit.hash]);
b69ab31204 }
b69ab31205 }
b69ab31206 // Show the drawer.
b69ab31207 writeAtom(islDrawerState, state => ({
b69ab31208 ...state,
b69ab31209 right: {
b69ab31210 ...state.right,
b69ab31211 collapsed: false,
b69ab31212 },
b69ab31213 }));
b69ab31214 if (commit.isDot) {
b69ab31215 // if we happened to be in commit mode, swap to amend mode so you see the details instead
b69ab31216 writeAtom(commitMode, 'amend');
b69ab31217 }
b69ab31218 }, [overrideSelection, isSelected, commit.hash, commit.isDot]);
b69ab31219 return {isSelected, onClickToSelect, onDoubleClickToShowDrawer};
b69ab31220}
b69ab31221
b69ab31222export function useArrowKeysToChangeSelection() {
b69ab31223 const cb = useCallback((which: ISLCommandName) => {
b69ab31224 if (which === 'OpenDetails') {
b69ab31225 writeAtom(islDrawerState, previous => ({
b69ab31226 ...previous,
b69ab31227 right: {
b69ab31228 ...previous.right,
b69ab31229 collapsed: false,
b69ab31230 },
b69ab31231 }));
b69ab31232 }
b69ab31233
b69ab31234 const dag = readAtom(dagWithPreviews);
b69ab31235 const [sortIndex, sorted] = dag.defaultSortAscIndex();
b69ab31236
b69ab31237 if (sorted.length === 0) {
b69ab31238 return;
b69ab31239 }
b69ab31240
b69ab31241 const lastSelected = readAtom(previouslySelectedCommit);
b69ab31242 const lastIndex = lastSelected == null ? undefined : sortIndex.get(lastSelected);
b69ab31243
b69ab31244 const nextSelectableHash = (step = 1 /* 1: up; -1: down */, start = lastIndex ?? 0) => {
b69ab31245 let index = start;
b69ab31246 while (index > 0) {
b69ab31247 index += step;
b69ab31248 const hash = sorted.at(index);
b69ab31249 if (hash == null) {
b69ab31250 return undefined;
b69ab31251 }
ab83ad3252 return hash;
b69ab31253 }
b69ab31254 };
b69ab31255
b69ab31256 const existingSelection = readAtom(selectedCommits);
b69ab31257 if (existingSelection.size === 0) {
b69ab31258 if (which === 'SelectDownwards' || which === 'ContinueSelectionDownwards') {
b69ab31259 const top = nextSelectableHash(-1, sorted.length);
b69ab31260 if (top != null) {
b69ab31261 writeAtom(selectedCommits, new Set([top]));
b69ab31262 writeAtom(previouslySelectedCommit, top);
b69ab31263 }
b69ab31264 }
b69ab31265 return;
b69ab31266 }
b69ab31267
b69ab31268 if (lastSelected == null || lastIndex == null) {
b69ab31269 return;
b69ab31270 }
b69ab31271
b69ab31272 let newSelected: Hash | undefined;
b69ab31273 let extendSelection = false;
b69ab31274
b69ab31275 switch (which) {
b69ab31276 case 'SelectUpwards': {
b69ab31277 newSelected = nextSelectableHash(1);
b69ab31278 break;
b69ab31279 }
b69ab31280 case 'SelectDownwards': {
b69ab31281 newSelected = nextSelectableHash(-1);
b69ab31282 break;
b69ab31283 }
b69ab31284 case 'ContinueSelectionUpwards': {
b69ab31285 newSelected = nextSelectableHash(1);
b69ab31286 extendSelection = true;
b69ab31287 break;
b69ab31288 }
b69ab31289 case 'ContinueSelectionDownwards': {
b69ab31290 newSelected = nextSelectableHash(-1);
b69ab31291 extendSelection = true;
b69ab31292 break;
b69ab31293 }
b69ab31294 }
b69ab31295
b69ab31296 if (newSelected != null) {
b69ab31297 const newHash = newSelected;
b69ab31298 writeAtom(selectedCommits, last =>
b69ab31299 extendSelection ? new Set([...last, newHash]) : new Set([newHash]),
b69ab31300 );
b69ab31301 writeAtom(previouslySelectedCommit, newHash);
b69ab31302 }
b69ab31303 }, []);
b69ab31304
b69ab31305 useCommand('OpenDetails', () => cb('OpenDetails'));
b69ab31306 useCommand('SelectUpwards', () => cb('SelectUpwards'));
b69ab31307 useCommand('SelectDownwards', () => cb('SelectDownwards'));
b69ab31308 useCommand('ContinueSelectionUpwards', () => cb('ContinueSelectionUpwards'));
b69ab31309 useCommand('ContinueSelectionDownwards', () => cb('ContinueSelectionDownwards'));
b69ab31310 useSelectAllCommitsShortcut();
b69ab31311}
b69ab31312
b69ab31313export function useBackspaceToHideSelected(): void {
b69ab31314 const cb = useCallback(() => {
b69ab31315 // Though you can select multiple commits, our preview system doesn't handle that very well.
b69ab31316 // Just preview hiding the most recently selected commit.
b69ab31317 // Another sensible behavior would be to inspect the tree of commits selected
b69ab31318 // and find if there's a single common ancestor to hide. That won't work in all cases though.
b69ab31319 const mostRecent = readAtom(previouslySelectedCommit);
b69ab31320 let hashToHide = mostRecent;
b69ab31321 if (hashToHide == null) {
b69ab31322 const selection = readAtom(selectedCommits);
b69ab31323 if (selection != null) {
b69ab31324 hashToHide = firstOfIterable(selection.values());
b69ab31325 }
b69ab31326 }
b69ab31327 if (hashToHide == null) {
b69ab31328 return;
b69ab31329 }
b69ab31330
b69ab31331 const commitToHide = readAtom(latestDag).get(hashToHide);
b69ab31332 if (commitToHide == null) {
b69ab31333 return;
b69ab31334 }
b69ab31335
b69ab31336 writeAtom(
b69ab31337 operationBeingPreviewed,
b69ab31338 new HideOperation(latestSuccessorUnlessExplicitlyObsolete(commitToHide)),
b69ab31339 );
b69ab31340 }, []);
b69ab31341
b69ab31342 useCommand('HideSelectedCommits', cb);
b69ab31343}
b69ab31344
b69ab31345export function useShortcutToRebaseSelected(): void {
b69ab31346 const runOperation = useRunOperation();
b69ab31347
b69ab31348 const cb = useCallback(async () => {
b69ab31349 const dag = readAtom(dagWithPreviews);
b69ab31350 const baseCommit = findPublicBaseAncestor(dag);
b69ab31351 if (!baseCommit) {
b69ab31352 return;
b69ab31353 }
b69ab31354 const baseCommitRevset = exactRevset(baseCommit.hash);
b69ab31355
b69ab31356 const selectedCommits = readAtom(selectedCommitInfos);
b69ab31357 const selectedRevsets = selectedCommits
b69ab31358 .filter(commitInfo => findPublicBaseAncestor(dag, commitInfo.hash)?.hash !== baseCommit.hash)
b69ab31359 .map(latestSuccessorUnlessExplicitlyObsolete);
b69ab31360
b69ab31361 if (selectedRevsets.length === 0) {
b69ab31362 return;
b69ab31363 } else if (selectedRevsets.length === 1) {
b69ab31364 writeAtom(
b69ab31365 operationBeingPreviewed,
b69ab31366 () => new RebaseOperation(selectedRevsets[0], baseCommitRevset),
b69ab31367 );
b69ab31368 } else {
b69ab31369 if (
b69ab31370 await platform.confirm(
b69ab31371 t('Are you sure you want to rebase $count commits?', {count: selectedRevsets.length}),
b69ab31372 )
b69ab31373 ) {
b69ab31374 runOperation(new BulkRebaseOperation(selectedRevsets, baseCommitRevset));
b69ab31375 }
b69ab31376 }
b69ab31377 }, [runOperation]);
b69ab31378 useCommand('RebaseOntoCurrentStackBase', cb);
b69ab31379}