16.6 KB523 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 {Dag} from './dag/dag';
9import type {CommitTreeWithPreviews} from './getCommitTree';
10import type {Operation} from './operations/Operation';
11import type {OperationInfo, OperationList} from './operationsState';
12import type {ChangedFile, CommitInfo, Hash, MergeConflicts, UncommittedChanges} from './types';
13
14import {atom, useAtom, useAtomValue} from 'jotai';
15import {useEffect} from 'react';
16import {notEmpty, nullthrows} from 'shared/utils';
17import {latestSuccessorsMapAtom} from './SuccessionTracker';
18import {getTracker} from './analytics/globalTracker';
19import {focusMode} from './atoms/FocusModeState';
20import {YOU_ARE_HERE_VIRTUAL_COMMIT} from './dag/virtualCommit';
21import {getCommitTree, walkTreePostorder} from './getCommitTree';
22import {getOpName} from './operations/Operation';
23import {operationBeingPreviewed, operationList, queuedOperations} from './operationsState';
24import {
25 latestCommits,
26 latestCommitsData,
27 latestDag,
28 latestHeadCommit,
29 latestUncommittedChanges,
30 latestUncommittedChangesData,
31 mergeConflicts,
32} from './serverAPIState';
33
34export enum CommitPreview {
35 REBASE_ROOT = 'rebase-root',
36 REBASE_DESCENDANT = 'rebase-descendant',
37 REBASE_OLD = 'rebase-old',
38 REBASE_OPTIMISTIC_ROOT = 'rebase-optimistic-root',
39 REBASE_OPTIMISTIC_DESCENDANT = 'rebase-optimistic-descendant',
40 GOTO_DESTINATION = 'goto-destination',
41 GOTO_PREVIOUS_LOCATION = 'goto-previous-location',
42 HIDDEN_ROOT = 'hidden-root',
43 HIDDEN_DESCENDANT = 'hidden-descendant',
44 STACK_EDIT_ROOT = 'stack-edit-root',
45 STACK_EDIT_DESCENDANT = 'stack-edit-descendant',
46 FOLD_PREVIEW = 'fold-preview',
47 FOLD = 'fold',
48 // Commit being rendered in some other context than the commit tree,
49 // such as the commit info sidebar
50 NON_ACTIONABLE_COMMIT = 'non-actionable-commit',
51}
52
53/**
54 * Alter the set of Uncommitted Changes.
55 */
56export type ApplyUncommittedChangesPreviewsFuncType = (
57 changes: UncommittedChanges,
58) => UncommittedChanges;
59
60/**
61 * Alter the set of Merge Conflicts.
62 */
63export type ApplyMergeConflictsPreviewsFuncType = (
64 conflicts: MergeConflicts | undefined,
65) => MergeConflicts | undefined;
66
67function applyPreviewsToChangedFiles(
68 files: Array<ChangedFile>,
69 list: OperationList,
70 queued: Array<Operation>,
71): Array<ChangedFile> {
72 const currentOperation = list.currentOperation;
73
74 // gather operations from past, current, and queued commands which could have optimistic state appliers
75 type Applier = (
76 context: UncommittedChangesPreviewContext,
77 ) => ApplyUncommittedChangesPreviewsFuncType | undefined;
78 const appliersSources: Array<Applier> = [];
79
80 // previous commands
81 for (const op of list.operationHistory) {
82 if (op != null && !op.hasCompletedUncommittedChangesOptimisticState) {
83 if (op.operation.makeOptimisticUncommittedChangesApplier != null) {
84 appliersSources.push(
85 op.operation.makeOptimisticUncommittedChangesApplier.bind(op.operation),
86 );
87 }
88 }
89 }
90
91 // currently running/last command
92 if (
93 currentOperation != null &&
94 !currentOperation.hasCompletedUncommittedChangesOptimisticState &&
95 // don't show optimistic state if we hit an error
96 (currentOperation.exitCode == null || currentOperation.exitCode === 0)
97 ) {
98 if (currentOperation.operation.makeOptimisticUncommittedChangesApplier != null) {
99 appliersSources.push(
100 currentOperation.operation.makeOptimisticUncommittedChangesApplier.bind(
101 currentOperation.operation,
102 ),
103 );
104 }
105 }
106
107 // queued commands
108 for (const op of queued) {
109 if (op != null) {
110 if (op.makeOptimisticUncommittedChangesApplier != null) {
111 appliersSources.push(op.makeOptimisticUncommittedChangesApplier.bind(op));
112 }
113 }
114 }
115
116 // apply in order
117 if (appliersSources.length) {
118 let finalChanges = files;
119
120 for (const applierSource of appliersSources) {
121 const context: UncommittedChangesPreviewContext = {
122 uncommittedChanges: files,
123 };
124
125 const applier = applierSource(context);
126 if (applier == null) {
127 continue;
128 }
129
130 finalChanges = applier(finalChanges);
131 }
132 return finalChanges;
133 }
134
135 return files;
136}
137
138function applyPreviewsToMergeConflicts(
139 conflicts: MergeConflicts,
140 list: OperationList,
141 queued: Array<Operation>,
142): MergeConflicts | undefined {
143 const currentOperation = list.currentOperation;
144 if (conflicts.state !== 'loaded') {
145 return conflicts;
146 }
147
148 // gather operations from past, current, and queued commands which could have optimistic state appliers
149 type Applier = (
150 context: MergeConflictsPreviewContext,
151 ) => ApplyMergeConflictsPreviewsFuncType | undefined;
152 const appliersSources: Array<Applier> = [];
153
154 // previous commands
155 for (const op of list.operationHistory) {
156 if (op != null && !op.hasCompletedMergeConflictsOptimisticState) {
157 if (op.operation.makeOptimisticMergeConflictsApplier != null) {
158 appliersSources.push(op.operation.makeOptimisticMergeConflictsApplier.bind(op.operation));
159 }
160 }
161 }
162
163 // currently running/last command
164 if (
165 currentOperation != null &&
166 !currentOperation.hasCompletedMergeConflictsOptimisticState &&
167 // don't show optimistic state if we hit an error
168 (currentOperation.exitCode == null || currentOperation.exitCode === 0)
169 ) {
170 if (currentOperation.operation.makeOptimisticMergeConflictsApplier != null) {
171 appliersSources.push(
172 currentOperation.operation.makeOptimisticMergeConflictsApplier.bind(
173 currentOperation.operation,
174 ),
175 );
176 }
177 }
178
179 // queued commands
180 for (const op of queued) {
181 if (op != null) {
182 if (op.makeOptimisticMergeConflictsApplier != null) {
183 appliersSources.push(op.makeOptimisticMergeConflictsApplier.bind(op));
184 }
185 }
186 }
187
188 // apply in order
189 if (appliersSources.length) {
190 let finalChanges: MergeConflicts | undefined = conflicts;
191
192 for (const applierSource of appliersSources) {
193 const context: MergeConflictsPreviewContext = {
194 conflicts,
195 };
196
197 const applier = applierSource(context);
198 if (applier == null) {
199 continue;
200 }
201
202 finalChanges = applier(finalChanges);
203 }
204 return finalChanges;
205 }
206 return conflicts;
207}
208
209export const uncommittedChangesWithPreviews = atom<Array<ChangedFile>>(get => {
210 const list = get(operationList);
211 const queued = get(queuedOperations);
212 const uncommittedChanges = get(latestUncommittedChanges);
213
214 return applyPreviewsToChangedFiles(uncommittedChanges, list, queued);
215});
216
217export const optimisticMergeConflicts = atom(get => {
218 const list = get(operationList);
219 const queued = get(queuedOperations);
220 const conflicts = get(mergeConflicts);
221 if (conflicts?.files == null) {
222 return conflicts;
223 }
224
225 return applyPreviewsToMergeConflicts(conflicts, list, queued);
226});
227
228export type TreeWithPreviews = {
229 trees: Array<CommitTreeWithPreviews>;
230 treeMap: Map<Hash, CommitTreeWithPreviews>;
231 headCommit?: CommitInfo;
232};
233
234export type WithPreviewType = {
235 previewType?: CommitPreview;
236 /**
237 * Insertion batch. Larger: later inserted.
238 * All 'sl log' commits share a same initial number.
239 * Later previews might have larger numbers.
240 * Used for sorting.
241 */
242 seqNumber?: number;
243};
244
245export type {Dag};
246
247export const dagWithPreviews = atom(get => {
248 const originalDag = get(latestDag);
249 const list = get(operationList);
250 const queued = get(queuedOperations);
251 const currentOperation = list.currentOperation;
252 const history = list.operationHistory;
253 const currentPreview = get(operationBeingPreviewed);
254 let dag = originalDag;
255
256 const focus = get(focusMode);
257 if (focus) {
258 const current = dag.resolve('.');
259 if (current) {
260 const currentStack = dag.descendants(
261 dag.ancestors(dag.draft(current.hash), {within: dag.draft()}),
262 );
263 const related = dag.descendants(
264 dag.successors(currentStack).union(dag.predecessors(currentStack)),
265 );
266 const toKeep = currentStack
267 .union(YOU_ARE_HERE_VIRTUAL_COMMIT.hash) // ensure we always show "You Are Here"
268 .union(related);
269 const toRemove = dag.draft().subtract(toKeep);
270 dag = dag.remove(toRemove);
271 }
272 }
273
274 for (const op of optimisticOperations({history, queued, currentOperation})) {
275 dag = op.optimisticDag(dag);
276 }
277 if (currentPreview) {
278 dag = currentPreview.previewDag(dag);
279 }
280 return dag;
281});
282
283export const treeWithPreviews = atom(get => {
284 const dag = get(dagWithPreviews);
285 const commits = [...dag.values()];
286 const trees = getCommitTree(commits);
287
288 let headCommit = get(latestHeadCommit);
289 // The headCommit might be changed by dag previews. Double check.
290 if (headCommit && !dag.get(headCommit.hash)?.isDot) {
291 headCommit = dag.resolve('.');
292 }
293 // Open-code latestCommitTreeMap to pick up tree changes done by `dag`.
294 const treeMap = new Map<Hash, CommitTreeWithPreviews>();
295 for (const tree of walkTreePostorder(trees)) {
296 treeMap.set(tree.info.hash, tree);
297 }
298
299 return {trees, treeMap, headCommit};
300});
301
302/** Yield operations that might need optimistic state. */
303function* optimisticOperations(props: {
304 history: OperationInfo[];
305 queued: Operation[];
306 currentOperation?: OperationInfo;
307}): Generator<Operation> {
308 const {history, queued, currentOperation} = props;
309
310 // previous commands
311 for (const op of history) {
312 if (op != null && !op.hasCompletedOptimisticState) {
313 yield op.operation;
314 }
315 }
316
317 // currently running/last command
318 if (
319 currentOperation != null &&
320 !currentOperation.hasCompletedOptimisticState &&
321 // don't show optimistic state if we hit an error
322 (currentOperation.exitCode == null || currentOperation.exitCode === 0)
323 ) {
324 yield currentOperation.operation;
325 }
326
327 // queued commands
328 for (const op of queued) {
329 if (op != null) {
330 yield op;
331 }
332 }
333}
334
335/**
336 * Mark operations as completed when their optimistic applier is no longer needed.
337 * Similarly marks uncommitted changes optimistic state resolved.
338 * n.b. this must be a useEffect since React doesn't like setCurrentOperation getting called during render
339 * when ongoingOperation is used elsewhere in the tree
340 */
341export function useMarkOperationsCompleted(): void {
342 const fetchedCommits = useAtomValue(latestCommitsData);
343 const commits = useAtomValue(latestCommits);
344 const uncommittedChanges = useAtomValue(latestUncommittedChangesData);
345 const conflicts = useAtomValue(mergeConflicts);
346 const successorMap = useAtomValue(latestSuccessorsMapAtom);
347
348 const [list, setOperationList] = useAtom(operationList);
349
350 // Mark operations as completed when their optimistic applier is no longer needed
351 // n.b. this must be a useEffect since React doesn't like setCurrentOperation getting called during render
352 // when ongoingOperation is used elsewhere in the tree
353 useEffect(() => {
354 const toMarkResolved: Array<ReturnType<typeof shouldMarkOptimisticChangesResolved>> = [];
355 const uncommittedContext = {
356 uncommittedChanges: uncommittedChanges.files ?? [],
357 };
358 const mergeConflictsContext = {
359 conflicts,
360 };
361 const currentOperation = list.currentOperation;
362
363 for (const operation of [...list.operationHistory, currentOperation]) {
364 if (operation) {
365 toMarkResolved.push(
366 shouldMarkOptimisticChangesResolved(operation, uncommittedContext, mergeConflictsContext),
367 );
368 }
369 }
370 if (toMarkResolved.some(notEmpty)) {
371 const operationHistory = [...list.operationHistory];
372 const currentOperation =
373 list.currentOperation == null ? undefined : {...list.currentOperation};
374 for (let i = 0; i < toMarkResolved.length - 1; i++) {
375 if (toMarkResolved[i]?.commits) {
376 operationHistory[i] = {
377 ...operationHistory[i],
378 hasCompletedOptimisticState: true,
379 };
380 }
381 if (toMarkResolved[i]?.files) {
382 operationHistory[i] = {
383 ...operationHistory[i],
384 hasCompletedUncommittedChangesOptimisticState: true,
385 };
386 }
387 if (toMarkResolved[i]?.conflicts) {
388 operationHistory[i] = {
389 ...operationHistory[i],
390 hasCompletedMergeConflictsOptimisticState: true,
391 };
392 }
393 }
394 const markCurrentOpResolved = toMarkResolved[toMarkResolved.length - 1];
395 if (markCurrentOpResolved && currentOperation != null) {
396 if (markCurrentOpResolved.commits) {
397 currentOperation.hasCompletedOptimisticState = true;
398 }
399 if (markCurrentOpResolved.files) {
400 currentOperation.hasCompletedUncommittedChangesOptimisticState = true;
401 }
402 if (markCurrentOpResolved.conflicts) {
403 currentOperation.hasCompletedMergeConflictsOptimisticState = true;
404 }
405 }
406 setOperationList({operationHistory, currentOperation});
407 }
408
409 function shouldMarkOptimisticChangesResolved(
410 operation: OperationInfo,
411 uncommittedChangesContext: UncommittedChangesPreviewContext,
412 mergeConflictsContext: MergeConflictsPreviewContext,
413 ): {commits: boolean; files: boolean; conflicts: boolean} | undefined {
414 let files = false;
415 let commits = false;
416 let conflicts = false;
417
418 if (operation != null && !operation.hasCompletedUncommittedChangesOptimisticState) {
419 if (operation.operation.makeOptimisticUncommittedChangesApplier != null) {
420 const optimisticApplier =
421 operation.operation.makeOptimisticUncommittedChangesApplier(uncommittedChangesContext);
422 if (operation.exitCode != null) {
423 if (optimisticApplier == null || operation.exitCode !== 0) {
424 files = true;
425 } else if (
426 uncommittedChanges.fetchStartTimestamp > nullthrows(operation.endTime).valueOf()
427 ) {
428 getTracker()?.track('OptimisticFilesStateForceResolved', {extras: {}});
429 files = true;
430 }
431 }
432 } else if (operation.exitCode != null) {
433 files = true;
434 }
435 }
436
437 if (operation != null && !operation.hasCompletedMergeConflictsOptimisticState) {
438 if (operation.operation.makeOptimisticMergeConflictsApplier != null) {
439 const optimisticApplier =
440 operation.operation.makeOptimisticMergeConflictsApplier(mergeConflictsContext);
441 if (operation.exitCode != null) {
442 if (optimisticApplier == null || operation.exitCode !== 0) {
443 conflicts = true;
444 } else if (
445 (mergeConflictsContext.conflicts?.fetchStartTimestamp ?? 0) >
446 nullthrows(operation.endTime).valueOf()
447 ) {
448 getTracker()?.track('OptimisticConflictsStateForceResolved', {
449 extras: {operation: getOpName(operation.operation)},
450 });
451 conflicts = true;
452 }
453 }
454 } else if (operation.exitCode != null) {
455 conflicts = true;
456 }
457 }
458
459 if (operation != null && !operation.hasCompletedOptimisticState) {
460 const endTime = operation.endTime?.valueOf();
461 if (endTime && fetchedCommits.fetchStartTimestamp >= endTime) {
462 commits = true;
463 }
464 }
465
466 if (commits || files || conflicts) {
467 return {commits, files, conflicts};
468 }
469 return undefined;
470 }
471 }, [
472 list,
473 setOperationList,
474 commits,
475 uncommittedChanges,
476 conflicts,
477 fetchedCommits,
478 successorMap,
479 ]);
480}
481
482export type UncommittedChangesPreviewContext = {
483 uncommittedChanges: UncommittedChanges;
484};
485
486export type MergeConflictsPreviewContext = {
487 conflicts: MergeConflicts | undefined;
488};
489
490// eslint-disable-next-line @typescript-eslint/no-explicit-any
491type Class<T> = new (...args: any[]) => T;
492/**
493 * React hook which looks in operation queue and history to see if a
494 * particular operation is running or queued to run.
495 * ```
496 * const isRunning = useIsOperationRunningOrQueued(PullOperation);
497 * ```
498 */
499export function useIsOperationRunningOrQueued(
500 cls: Class<Operation>,
501): 'running' | 'queued' | undefined {
502 const list = useAtomValue(operationList);
503 const queued = useAtomValue(queuedOperations);
504 if (list.currentOperation?.operation instanceof cls && list.currentOperation?.exitCode == null) {
505 return 'running';
506 } else if (queued.some(op => op instanceof cls)) {
507 return 'queued';
508 }
509 return undefined;
510}
511
512export function useMostRecentPendingOperation(): Operation | undefined {
513 const list = useAtomValue(operationList);
514 const queued = useAtomValue(queuedOperations);
515 if (queued.length > 0) {
516 return queued.at(-1);
517 }
518 if (list.currentOperation?.exitCode == null) {
519 return list.currentOperation?.operation;
520 }
521 return undefined;
522}
523