5.4 KB154 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, Hash} from './types';
9
10import {Button} from 'isl-components/Button';
11import {Icon} from 'isl-components/Icon';
12import {Tooltip} from 'isl-components/Tooltip';
13import {atom, useAtomValue} from 'jotai';
14import {useCallback} from 'react';
15import {editedCommitMessages} from './CommitInfoView/CommitInfoState';
16import {
17 applyEditedFields,
18 commitMessageFieldsSchema,
19 commitMessageFieldsToString,
20 mergeManyCommitMessageFields,
21 parseCommitMessageFields,
22} from './CommitInfoView/CommitMessageFields';
23import {T, t} from './i18n';
24import {readAtom, writeAtom} from './jotaiUtils';
25import {
26 FOLD_COMMIT_PREVIEW_HASH_PREFIX,
27 FoldOperation,
28 getFoldRangeCommitHash,
29} from './operations/FoldOperation';
30import {operationBeingPreviewed, useRunPreviewedOperation} from './operationsState';
31import {type Dag, dagWithPreviews} from './previews';
32import {selectedCommits} from './selection';
33import {firstOfIterable} from './utils';
34
35/**
36 * If the selected commits are linear, contiguous, and non-branching, they may be folded together.
37 * This selector gives the range of commits that can be folded, if any,
38 * so a button may be shown to do the fold.
39 */
40export const foldableSelection = atom(get => {
41 const selection = get(selectedCommits);
42 if (selection.size < 2) {
43 return undefined;
44 }
45 const dag = get(dagWithPreviews);
46 const foldable = getFoldableRange(selection, dag);
47 return foldable;
48});
49
50/**
51 * Given a set of selected commits, order them into an array from bottom to top.
52 * If commits are not contiguous, returns undefined.
53 * This selection must be linear and contiguous: no branches out are allowed.
54 * This constitutes a set of commits that may be "folded"/combined into a single commit via `sl fold`.
55 */
56export function getFoldableRange(selection: Set<Hash>, dag: Dag): Array<CommitInfo> | undefined {
57 const set = dag.present(selection);
58 if (set.size <= 1) {
59 return undefined;
60 }
61 const head = dag.heads(set);
62 if (
63 head.size !== 1 ||
64 dag.roots(set).size !== 1 ||
65 dag.merge(set).size > 0 ||
66 dag.public_(set).size > 0 ||
67 // only head can have other children
68 dag.children(set.subtract(head)).subtract(set).size > 0
69 ) {
70 return undefined;
71 }
72 return dag.getBatch(dag.sortAsc(set, {gap: false}));
73}
74
75export function FoldButton({commit}: {commit?: CommitInfo}) {
76 const foldable = useAtomValue(foldableSelection);
77 const onClick = useCallback(() => {
78 if (foldable == null) {
79 return;
80 }
81 const schema = readAtom(commitMessageFieldsSchema);
82 if (schema == null) {
83 return;
84 }
85 const messageFields = mergeManyCommitMessageFields(
86 schema,
87 foldable.map(commit => parseCommitMessageFields(schema, commit.title, commit.description)),
88 );
89 const message = commitMessageFieldsToString(schema, messageFields);
90 writeAtom(operationBeingPreviewed, new FoldOperation(foldable, message));
91 writeAtom(selectedCommits, new Set([getFoldRangeCommitHash(foldable, /* isPreview */ true)]));
92 }, [foldable]);
93 if (foldable == null || (commit != null && foldable?.[0]?.hash !== commit.hash)) {
94 return null;
95 }
96 return (
97 <Tooltip title={t('Combine selected commits into one commit')}>
98 <Button onClick={onClick}>
99 <Icon icon="fold" slot="start" />
100 <T replace={{$count: foldable.length}}>Combine $count commits</T>
101 </Button>
102 </Tooltip>
103 );
104}
105
106/**
107 * Make a new copy of the FoldOperation with the latest edited message for the combined preview.
108 * This allows running the fold operation to use the newly typed message.
109 */
110export function updateFoldedMessageWithEditedMessage(): FoldOperation | undefined {
111 const beingPreviewed = readAtom(operationBeingPreviewed);
112 if (beingPreviewed != null && beingPreviewed instanceof FoldOperation) {
113 const range = beingPreviewed.getFoldRange();
114 const combinedHash = getFoldRangeCommitHash(range, /* isPreview */ true);
115 const [existingTitle, existingMessage] = beingPreviewed.getFoldedMessage();
116 const editedMessage = readAtom(editedCommitMessages(combinedHash));
117
118 const schema = readAtom(commitMessageFieldsSchema);
119 if (schema == null) {
120 return undefined;
121 }
122
123 const old = parseCommitMessageFields(schema, existingTitle, existingMessage);
124 const message = editedMessage == null ? old : applyEditedFields(old, editedMessage);
125
126 const newMessage = commitMessageFieldsToString(schema, message);
127
128 return new FoldOperation(range, newMessage);
129 }
130}
131
132export function useRunFoldPreview(): [cancel: () => unknown, run: () => unknown] {
133 const handlePreviewedOperation = useRunPreviewedOperation();
134 const run = useCallback(() => {
135 const foldOperation = updateFoldedMessageWithEditedMessage();
136 if (foldOperation == null) {
137 return;
138 }
139 handlePreviewedOperation(/* isCancel */ false, foldOperation);
140 // select the optimistic commit instead of the preview commit
141 writeAtom(selectedCommits, last =>
142 last.size === 1 && firstOfIterable(last.values())?.startsWith(FOLD_COMMIT_PREVIEW_HASH_PREFIX)
143 ? new Set([getFoldRangeCommitHash(foldOperation.getFoldRange(), /* isPreview */ false)])
144 : last,
145 );
146 }, [handlePreviewedOperation]);
147 return [
148 () => {
149 handlePreviewedOperation(/* isCancel */ true);
150 },
151 run,
152 ];
153}
154