20.0 KB659 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 {RecordOf} from 'immutable';
9import type {ExportStack} from 'shared/types/stack';
10import type {Hash} from '../../types';
11import type {CommitRev, CommitState} from '../commitStackState';
12
13import {List, Record} from 'immutable';
14import {atom, useAtom} from 'jotai';
15import {nullthrows} from 'shared/utils';
16import clientToServerAPI from '../../ClientToServerAPI';
17import {latestCommitMessageFieldsWithEdits} from '../../CommitInfoView/CommitInfoState';
18import {
19 commitMessageFieldsSchema,
20 commitMessageFieldsToString,
21} from '../../CommitInfoView/CommitMessageFields';
22import {getTracker} from '../../analytics/globalTracker';
23import {WDIR_NODE} from '../../dag/virtualCommit';
24import {t} from '../../i18n';
25import {readAtom, writeAtom} from '../../jotaiUtils';
26import {waitForNothingRunning} from '../../operationsState';
27import {uncommittedSelection} from '../../partialSelection';
28import {CommitStackState} from '../../stackEdit/commitStackState';
29import {assert, registerDisposable} from '../../utils';
30import {prev} from '../revMath';
31
32/**
33 * The "edit stack" dialog state that works with undo/redo in the dialog.
34 * Extra states that do not need undo/redo support (ex. which tab is active)
35 * are not here.
36 */
37type StackStateWithOperationProps = {
38 op: StackEditOpDescription;
39 state: CommitStackState;
40 // Extra states for different kinds of operations.
41 /** The split range selected in the "Split" tab. */
42 splitRange: SplitRangeRecord;
43};
44
45type Intention = 'general' | 'split' | 'absorb';
46
47/** Description of a stack edit operation. Used for display purpose. */
48export type StackEditOpDescription =
49 | {
50 name: 'move';
51 offset: number;
52 /** Count of dependencies excluding self. */
53 depCount?: number;
54 commit: CommitState;
55 }
56 | {
57 name: 'swap';
58 }
59 | {
60 name: 'drop';
61 commit: CommitState;
62 }
63 | {
64 name: 'fold';
65 commit: CommitState;
66 }
67 | {name: 'import'}
68 | {name: 'insertBlankCommit'}
69 | {name: 'fileStack'; fileDesc: string}
70 | {name: 'split'; path: string}
71 | {name: 'splitWithAI'}
72 | {name: 'metaedit'; commit: CommitState}
73 | {name: 'absorbMove'; commit: CommitState};
74
75type SplitRangeProps = {
76 startKey: string;
77 endKey: string;
78};
79export const SplitRangeRecord = Record<SplitRangeProps>({startKey: '', endKey: ''});
80export type SplitRangeRecord = RecordOf<SplitRangeProps>;
81
82// See `StackStateWithOperationProps`.
83const StackStateWithOperation = Record<StackStateWithOperationProps>({
84 op: {name: 'import'},
85 state: new CommitStackState([]),
86 splitRange: SplitRangeRecord(),
87});
88type StackStateWithOperation = RecordOf<StackStateWithOperationProps>;
89
90/** History of multiple states for undo/redo support. */
91type HistoryProps = {
92 history: List<StackStateWithOperation>;
93 currentIndex: number;
94};
95
96const HistoryRecord = Record<HistoryProps>({
97 history: List(),
98 currentIndex: 0,
99});
100type HistoryRecord = RecordOf<HistoryProps>;
101
102class History extends HistoryRecord {
103 get current(): StackStateWithOperation {
104 return nullthrows(this.history.get(this.currentIndex));
105 }
106
107 push(
108 state: CommitStackState,
109 op: StackEditOpDescription,
110 extras?: {
111 splitRange?: SplitRangeRecord;
112 },
113 ): History {
114 const newSplitRange = extras?.splitRange ?? this.current.splitRange;
115 const newHistory = this.history.slice(0, this.currentIndex + 1).push(
116 StackStateWithOperation({
117 op,
118 state,
119 splitRange: newSplitRange,
120 }),
121 );
122 return new History({
123 history: newHistory,
124 currentIndex: newHistory.size - 1,
125 });
126 }
127
128 /**
129 * Like `pop` then `push`, used to update the most recent operation as an optimization.
130 */
131 replaceTop(
132 state: CommitStackState,
133 op: StackEditOpDescription,
134 extras?: {
135 splitRange?: SplitRangeRecord;
136 },
137 ): History {
138 const newSplitRange = extras?.splitRange ?? this.current.splitRange;
139 const newHistory = this.history.slice(0, this.currentIndex).push(
140 StackStateWithOperation({
141 op,
142 state,
143 splitRange: newSplitRange,
144 }),
145 );
146 return new History({
147 history: newHistory,
148 currentIndex: newHistory.size - 1,
149 });
150 }
151
152 setSplitRange(range: SplitRangeRecord): History {
153 const newHistory = this.history.set(this.currentIndex, this.current.set('splitRange', range));
154 return new History({
155 history: newHistory,
156 currentIndex: newHistory.size - 1,
157 });
158 }
159
160 canUndo(): boolean {
161 return this.currentIndex > 0;
162 }
163
164 canRedo(): boolean {
165 return this.currentIndex + 1 < this.history.size;
166 }
167
168 undoOperationDescription(): StackEditOpDescription | undefined {
169 return this.canUndo() ? this.history.get(this.currentIndex)?.op : undefined;
170 }
171
172 redoOperationDescription(): StackEditOpDescription | undefined {
173 return this.canRedo() ? this.history.get(this.currentIndex + 1)?.op : undefined;
174 }
175
176 undo(): History {
177 return this.canUndo() ? this.set('currentIndex', this.currentIndex - 1) : this;
178 }
179
180 redo(): History {
181 return this.canRedo() ? this.set('currentIndex', this.currentIndex + 1) : this;
182 }
183}
184
185/** State related to stack editing UI. */
186type StackEditState = {
187 /**
188 * Commit hashes being edited.
189 * Empty means no editing is requested.
190 *
191 * Changing this to a non-empty value triggers `exportStack`
192 * message to the server.
193 */
194 hashes: Set<Hash>;
195
196 /** Intention of the stack editing. */
197 intention: Intention;
198
199 /**
200 * The (mutable) main history of stack states.
201 */
202 history: Loading<History>;
203};
204
205/** Lightweight recoil Loadable alternative that is not coupled with Promise. */
206export type Loading<T> =
207 | {
208 state: 'loading';
209 exportedStack:
210 | ExportStack /* Got the exported stack. Analyzing. */
211 | undefined /* Haven't got the exported stack. */;
212 message?: string;
213 }
214 | {state: 'hasValue'; value: T}
215 | {state: 'hasError'; error: string};
216
217/**
218 * Meant to be private. Exported for debugging purpose.
219 *
220 * You probably want to use `useStackEditState` and other atoms instead,
221 * which ensures consistency (ex. stack and requested hashes cannot be
222 * out of sync).
223 */
224export const stackEditState = (() => {
225 const inner = atom<StackEditState>({
226 hashes: new Set<Hash>(),
227 intention: 'general',
228 history: {state: 'loading', exportedStack: undefined},
229 });
230 return atom<StackEditState, [StackEditState | ((s: StackEditState) => StackEditState)], void>(
231 get => get(inner),
232 // Kick off stack analysis on receiving an exported stack.
233 (get, set, newValue) => {
234 const {hashes, intention, history} =
235 typeof newValue === 'function' ? newValue(get(inner)) : newValue;
236 if (hashes.size > 0 && history.state === 'loading' && history.exportedStack !== undefined) {
237 try {
238 let stack = new CommitStackState(history.exportedStack).buildFileStacks();
239 if (intention === 'absorb') {
240 // Perform absorb analysis. Note: the absorb use-case has an extra
241 // "wdir()" at the stack top for absorb purpose. When the intention
242 // is "general" or "split", there is no "wdir()" in the stack.
243 stack = stack.analyseAbsorb();
244 }
245 const historyValue = new History({
246 history: List([StackStateWithOperation({state: stack})]),
247 currentIndex: 0,
248 });
249 currentMetrics = {
250 commits: hashes.size,
251 fileStacks: stack.fileStacks.size,
252 fileStackRevs: stack.fileStacks.reduce((acc, f) => acc + f.source.revLength, 0),
253 splitFromSuggestion: currentMetrics.splitFromSuggestion,
254 };
255 currentMetricsStartTime = Date.now();
256 // Cannot write to self (`stackEditState`) here.
257 set(inner, {
258 hashes,
259 intention,
260 history: {state: 'hasValue', value: historyValue},
261 });
262 } catch (err) {
263 const msg = `Cannot construct stack ${err}`;
264 set(inner, {hashes, intention, history: {state: 'hasError', error: msg}});
265 }
266 } else {
267 set(inner, newValue);
268 }
269 },
270 );
271})();
272
273/**
274 * Read-only access to the stack being edited.
275 * This can be useful without going through `UseStackEditState`.
276 * This is an atom so it can be used as a dependency of other atoms.
277 */
278export const stackEditStack = atom<CommitStackState | undefined>(get => {
279 const state = get(stackEditState);
280 return state.history.state === 'hasValue' ? state.history.value.current.state : undefined;
281});
282
283// Subscribe to server exportedStack events.
284registerDisposable(
285 stackEditState,
286 clientToServerAPI.onMessageOfType('exportedStack', event => {
287 writeAtom(stackEditState, (prev): StackEditState => {
288 const {hashes, intention} = prev;
289 const revs = joinRevs(hashes);
290 if (revs !== event.revs) {
291 // Wrong stack. Ignore it.
292 return prev;
293 }
294 if (event.error != null) {
295 return {hashes, intention, history: {state: 'hasError', error: event.error}};
296 } else {
297 return {
298 hashes,
299 intention,
300 history: {
301 state: 'loading',
302 exportedStack: rewriteWdirContent(rewriteCommitMessagesInStack(event.stack)),
303 },
304 };
305 }
306 });
307 }),
308 import.meta.hot,
309);
310
311/**
312 * Update commits messages in an exported stack to include:
313 * 1. Any local edits the user has pending (these have already been confirmed by a modal at this point)
314 * 2. Any remote message changes from the server (which allows the titles in the edit stack UI to be up to date)
315 */
316function rewriteCommitMessagesInStack(stack: ExportStack): ExportStack {
317 const schema = readAtom(commitMessageFieldsSchema);
318 return stack.map(c => {
319 let text = c.text;
320 if (schema) {
321 const editedMessage = readAtom(latestCommitMessageFieldsWithEdits(c.node));
322 if (editedMessage != null) {
323 text = commitMessageFieldsToString(schema, editedMessage);
324 }
325 }
326 return {...c, text};
327 });
328}
329
330/**
331 * Update the file content of "wdir()" to match the current partial selection.
332 * `sl` does not know the current partial selection state tracked exclusively in ISL.
333 * So let's patch the `wdir()` commit (if exists) with the right content.
334 */
335function rewriteWdirContent(stack: ExportStack): ExportStack {
336 // Run `sl debugexportstack -r "wdir()" | python3 -m json.tool` to get a sense of the `ExportStack` format.
337 return stack.map(c => {
338 // 'f' * 40 means the wdir() commit.
339 if (c.node === WDIR_NODE) {
340 const selection = readAtom(uncommittedSelection);
341 if (c.files != null) {
342 for (const path in c.files) {
343 const selected = selection.getSimplifiedSelection(path);
344 if (selected === false) {
345 // Not selected. Drop the path.
346 delete c.files[path];
347 } else if (typeof selected === 'string') {
348 // Chunk-selected. Rewrite the content.
349 c.files[path] = {
350 ...c.files[path],
351 data: selected,
352 };
353 }
354 }
355 }
356 }
357 return c;
358 });
359}
360
361/**
362 * Commit hashes being stack edited for general purpose.
363 * Setting to a non-empty value (which can be using the revsetlang)
364 * triggers server-side loading.
365 *
366 * For advance use-cases, the "hashes" could be revset expressions.
367 */
368export const editingStackIntentionHashes = atom<
369 [Intention, Set<Hash | string>],
370 [[Intention, Set<Hash | string>]],
371 void
372>(
373 get => {
374 const state = get(stackEditState);
375 return [state.intention, state.hashes];
376 },
377 async (_get, set, newValue) => {
378 const [intention, hashes] = newValue;
379 const waiter = waitForNothingRunning();
380 if (waiter != null) {
381 set(stackEditState, {
382 hashes,
383 intention,
384 history: {
385 state: 'loading',
386 exportedStack: undefined,
387 message: t('Waiting for other commands to finish'),
388 },
389 });
390 await waiter;
391 }
392 if (hashes.size > 0) {
393 const revs = joinRevs(hashes);
394 // Search for 'exportedStack' below for code handling the response.
395 // For absorb's use-case, there could be untracked ('?') files that are selected.
396 // Those would not be reported by `exportStack -r "wdir()""`. However, absorb
397 // currently only works for edited files. So it's okay to ignore '?' selected
398 // files by not passing `--assume-tracked FILE` to request content of these files.
399 // In the future, we might want to make absorb support newly added files.
400 clientToServerAPI.postMessage({type: 'exportStack', revs});
401 }
402 set(stackEditState, {
403 hashes,
404 intention,
405 history: {state: 'loading', exportedStack: undefined},
406 });
407 },
408);
409
410/**
411 * State for check whether the stack is loaded or not.
412 * Use `useStackEditState` if you want to read or edit the stack.
413 *
414 * This is not `Loading<CommitStackState>` so `hasValue`
415 * states do not trigger re-render.
416 */
417export const loadingStackState = atom<Loading<null>>(get => {
418 const history = get(stackEditState).history;
419 if (history.state === 'hasValue') {
420 return hasValueState;
421 } else {
422 return history;
423 }
424});
425
426const hasValueState: Loading<null> = {state: 'hasValue', value: null};
427
428export const shouldAutoSplitState = atom<boolean>(false);
429
430/** APIs exposed via useStackEditState() */
431class UseStackEditState {
432 state: StackEditState;
433 setState: (_state: StackEditState) => void;
434
435 // derived properties.
436 private history: History;
437
438 constructor(state: StackEditState, setState: (_state: StackEditState) => void) {
439 this.state = state;
440 this.setState = setState;
441 assert(
442 state.history.state === 'hasValue',
443 'useStackEditState only works when the stack is loaded',
444 );
445 this.history = state.history.value;
446 }
447
448 get commitStack(): CommitStackState {
449 return this.history.current.state;
450 }
451
452 get splitRange(): SplitRangeRecord {
453 return this.history.current.splitRange;
454 }
455
456 get intention(): Intention {
457 return this.state.intention;
458 }
459
460 setSplitRange(range: SplitRangeRecord | string) {
461 const splitRange =
462 typeof range === 'string'
463 ? SplitRangeRecord({
464 startKey: range,
465 endKey: range,
466 })
467 : range;
468 const newHistory = this.history.setSplitRange(splitRange);
469 this.setHistory(newHistory);
470 }
471
472 push(commitStack: CommitStackState, op: StackEditOpDescription, splitRange?: SplitRangeRecord) {
473 if (commitStack.originalStack !== this.commitStack.originalStack) {
474 // Wrong stack. Discard.
475 return;
476 }
477 const newHistory = this.history.push(commitStack, op, {splitRange});
478 this.setHistory(newHistory);
479 }
480
481 /**
482 * Like `pop` then `push`, used to update the most recent operation as an optimization
483 * to avoid lots of tiny state changes in the history.
484 */
485 replaceTopOperation(
486 commitStack: CommitStackState,
487 op: StackEditOpDescription,
488 extras?: {
489 splitRange?: SplitRangeRecord;
490 },
491 ) {
492 if (commitStack.originalStack !== this.commitStack.originalStack) {
493 // Wrong stack. Discard.
494 return;
495 }
496 const newHistory = this.history.replaceTop(commitStack, op, extras);
497 this.setHistory(newHistory);
498 }
499
500 canUndo(): boolean {
501 return this.history.canUndo();
502 }
503
504 canRedo(): boolean {
505 return this.history.canRedo();
506 }
507
508 undo() {
509 this.setHistory(this.history.undo());
510 }
511
512 undoOperationDescription(): StackEditOpDescription | undefined {
513 return this.history.undoOperationDescription();
514 }
515
516 redoOperationDescription(): StackEditOpDescription | undefined {
517 return this.history.redoOperationDescription();
518 }
519
520 redo() {
521 this.setHistory(this.history.redo());
522 }
523
524 numHistoryEditsOfType(name: StackEditOpDescription['name']): number {
525 return this.history.history
526 .slice(0, this.history.currentIndex + 1)
527 .filter(s => s.op.name === name).size;
528 }
529
530 /**
531 * Count edits made after an AI split operation.
532 * This helps measure the edit rate - how often users modify AI suggestions.
533 * Returns the count of non-AI-split operations that occur after any splitWithAI operation.
534 */
535 countEditsAfterAiSplit(): number {
536 const historySlice = this.history.history.slice(0, this.history.currentIndex + 1);
537 let foundAiSplit = false;
538 let editsAfterAiSplit = 0;
539
540 for (const entry of historySlice) {
541 if (entry.op.name === 'splitWithAI') {
542 foundAiSplit = true;
543 } else if (foundAiSplit && entry.op.name !== 'import') {
544 // Count any non-import operations after an AI split
545 // Exclude 'import' as it's the initial state operation
546 editsAfterAiSplit++;
547 }
548 }
549
550 return editsAfterAiSplit;
551 }
552
553 private setHistory(newHistory: History) {
554 const {hashes, intention} = this.state;
555 this.setState({
556 hashes,
557 intention,
558 history: {state: 'hasValue', value: newHistory},
559 });
560 }
561}
562
563// Only export the type, not the constructor.
564export type {UseStackEditState};
565
566/**
567 * Get the stack edit state. The stack must be loaded already, that is,
568 * `loadingStackState.state` is `hasValue`. This is the main state for
569 * reading and updating the `CommitStackState`.
570 */
571// This is not a recoil selector for flexibility.
572// See https://github.com/facebookexperimental/Recoil/issues/673
573export function useStackEditState() {
574 const [state, setState] = useAtom(stackEditState);
575 return new UseStackEditState(state, setState);
576}
577
578/** Get revset expression for requested hashes. */
579function joinRevs(hashes: Set<Hash>): string {
580 return [...hashes].join('|');
581}
582
583type StackEditMetrics = {
584 // Managed by this file.
585 commits: number;
586 fileStacks: number;
587 fileStackRevs: number;
588 acceptedAiSplits?: number;
589 // Maintained by UI, via 'bumpStackEditMetric'.
590 undo?: number;
591 redo?: number;
592 fold?: number;
593 drop?: number;
594 moveUpDown?: number;
595 swapLeftRight?: number;
596 moveDnD?: number;
597 fileStackEdit?: number;
598 splitMoveFile?: number;
599 splitMoveLine?: number;
600 splitInsertBlank?: number;
601 splitChangeRange?: number;
602 splitFromSuggestion?: number;
603 clickedAiSplit?: number;
604 // Devmate split specific metrics for acceptance rate tracking
605 clickedDevmateSplit?: number;
606 // Track edits made after an AI split was applied (to measure edit rate)
607 editsAfterAiSplit?: number;
608};
609
610// Not atoms. They do not trigger re-render.
611let currentMetrics: StackEditMetrics = {commits: 0, fileStackRevs: 0, fileStacks: 0};
612let currentMetricsStartTime = 0;
613
614export function bumpStackEditMetric(key: keyof StackEditMetrics, count = 1) {
615 currentMetrics[key] = (currentMetrics[key] ?? 0) + count;
616}
617
618export function sendStackEditMetrics(stackEdit: UseStackEditState, save = true) {
619 const tracker = getTracker();
620 const duration = Date.now() - currentMetricsStartTime;
621 const intention = readAtom(stackEditState).intention;
622
623 // # accepted AI splits is how many AI split operations are remaining at the end
624 const numAiSplits = stackEdit.numHistoryEditsOfType('splitWithAI');
625 if (numAiSplits) {
626 bumpStackEditMetric('acceptedAiSplits', numAiSplits);
627 }
628
629 // Count edits made after AI splits (to measure edit rate)
630 // This counts any non-AI-split operations that occurred after a splitWithAI
631 const editsAfterAiSplit = stackEdit.countEditsAfterAiSplit();
632 if (editsAfterAiSplit > 0) {
633 bumpStackEditMetric('editsAfterAiSplit', editsAfterAiSplit);
634 }
635
636 tracker?.track('StackEditMetrics', {
637 duration,
638 extras: {...currentMetrics, save, intention},
639 });
640 currentMetrics.splitFromSuggestion = 0; // Reset for next time.
641}
642
643export {WDIR_NODE};
644
645export function findStartEndRevs(
646 stackEdit: UseStackEditState,
647): [CommitRev | undefined, CommitRev | undefined] {
648 const {splitRange, intention, commitStack} = stackEdit;
649 if (intention === 'split') {
650 return [1 as CommitRev, prev(commitStack.size as CommitRev)];
651 }
652 const startRev = commitStack.findCommitByKey(splitRange.startKey)?.rev;
653 let endRev = commitStack.findCommitByKey(splitRange.endKey)?.rev;
654 if (startRev == null || startRev > (endRev ?? -1)) {
655 endRev = undefined;
656 }
657 return [startRev, endRev];
658}
659