addons/isl/src/stackEdit/ui/stackEditState.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 {RecordOf} from 'immutable';
b69ab319import type {ExportStack} from 'shared/types/stack';
b69ab3110import type {Hash} from '../../types';
b69ab3111import type {CommitRev, CommitState} from '../commitStackState';
b69ab3112
b69ab3113import {List, Record} from 'immutable';
b69ab3114import {atom, useAtom} from 'jotai';
b69ab3115import {nullthrows} from 'shared/utils';
b69ab3116import clientToServerAPI from '../../ClientToServerAPI';
b69ab3117import {latestCommitMessageFieldsWithEdits} from '../../CommitInfoView/CommitInfoState';
b69ab3118import {
b69ab3119 commitMessageFieldsSchema,
b69ab3120 commitMessageFieldsToString,
b69ab3121} from '../../CommitInfoView/CommitMessageFields';
b69ab3122import {getTracker} from '../../analytics/globalTracker';
b69ab3123import {WDIR_NODE} from '../../dag/virtualCommit';
b69ab3124import {t} from '../../i18n';
b69ab3125import {readAtom, writeAtom} from '../../jotaiUtils';
b69ab3126import {waitForNothingRunning} from '../../operationsState';
b69ab3127import {uncommittedSelection} from '../../partialSelection';
b69ab3128import {CommitStackState} from '../../stackEdit/commitStackState';
b69ab3129import {assert, registerDisposable} from '../../utils';
b69ab3130import {prev} from '../revMath';
b69ab3131
b69ab3132/**
b69ab3133 * The "edit stack" dialog state that works with undo/redo in the dialog.
b69ab3134 * Extra states that do not need undo/redo support (ex. which tab is active)
b69ab3135 * are not here.
b69ab3136 */
b69ab3137type StackStateWithOperationProps = {
b69ab3138 op: StackEditOpDescription;
b69ab3139 state: CommitStackState;
b69ab3140 // Extra states for different kinds of operations.
b69ab3141 /** The split range selected in the "Split" tab. */
b69ab3142 splitRange: SplitRangeRecord;
b69ab3143};
b69ab3144
b69ab3145type Intention = 'general' | 'split' | 'absorb';
b69ab3146
b69ab3147/** Description of a stack edit operation. Used for display purpose. */
b69ab3148export type StackEditOpDescription =
b69ab3149 | {
b69ab3150 name: 'move';
b69ab3151 offset: number;
b69ab3152 /** Count of dependencies excluding self. */
b69ab3153 depCount?: number;
b69ab3154 commit: CommitState;
b69ab3155 }
b69ab3156 | {
b69ab3157 name: 'swap';
b69ab3158 }
b69ab3159 | {
b69ab3160 name: 'drop';
b69ab3161 commit: CommitState;
b69ab3162 }
b69ab3163 | {
b69ab3164 name: 'fold';
b69ab3165 commit: CommitState;
b69ab3166 }
b69ab3167 | {name: 'import'}
b69ab3168 | {name: 'insertBlankCommit'}
b69ab3169 | {name: 'fileStack'; fileDesc: string}
b69ab3170 | {name: 'split'; path: string}
b69ab3171 | {name: 'splitWithAI'}
b69ab3172 | {name: 'metaedit'; commit: CommitState}
b69ab3173 | {name: 'absorbMove'; commit: CommitState};
b69ab3174
b69ab3175type SplitRangeProps = {
b69ab3176 startKey: string;
b69ab3177 endKey: string;
b69ab3178};
b69ab3179export const SplitRangeRecord = Record<SplitRangeProps>({startKey: '', endKey: ''});
b69ab3180export type SplitRangeRecord = RecordOf<SplitRangeProps>;
b69ab3181
b69ab3182// See `StackStateWithOperationProps`.
b69ab3183const StackStateWithOperation = Record<StackStateWithOperationProps>({
b69ab3184 op: {name: 'import'},
b69ab3185 state: new CommitStackState([]),
b69ab3186 splitRange: SplitRangeRecord(),
b69ab3187});
b69ab3188type StackStateWithOperation = RecordOf<StackStateWithOperationProps>;
b69ab3189
b69ab3190/** History of multiple states for undo/redo support. */
b69ab3191type HistoryProps = {
b69ab3192 history: List<StackStateWithOperation>;
b69ab3193 currentIndex: number;
b69ab3194};
b69ab3195
b69ab3196const HistoryRecord = Record<HistoryProps>({
b69ab3197 history: List(),
b69ab3198 currentIndex: 0,
b69ab3199});
b69ab31100type HistoryRecord = RecordOf<HistoryProps>;
b69ab31101
b69ab31102class History extends HistoryRecord {
b69ab31103 get current(): StackStateWithOperation {
b69ab31104 return nullthrows(this.history.get(this.currentIndex));
b69ab31105 }
b69ab31106
b69ab31107 push(
b69ab31108 state: CommitStackState,
b69ab31109 op: StackEditOpDescription,
b69ab31110 extras?: {
b69ab31111 splitRange?: SplitRangeRecord;
b69ab31112 },
b69ab31113 ): History {
b69ab31114 const newSplitRange = extras?.splitRange ?? this.current.splitRange;
b69ab31115 const newHistory = this.history.slice(0, this.currentIndex + 1).push(
b69ab31116 StackStateWithOperation({
b69ab31117 op,
b69ab31118 state,
b69ab31119 splitRange: newSplitRange,
b69ab31120 }),
b69ab31121 );
b69ab31122 return new History({
b69ab31123 history: newHistory,
b69ab31124 currentIndex: newHistory.size - 1,
b69ab31125 });
b69ab31126 }
b69ab31127
b69ab31128 /**
b69ab31129 * Like `pop` then `push`, used to update the most recent operation as an optimization.
b69ab31130 */
b69ab31131 replaceTop(
b69ab31132 state: CommitStackState,
b69ab31133 op: StackEditOpDescription,
b69ab31134 extras?: {
b69ab31135 splitRange?: SplitRangeRecord;
b69ab31136 },
b69ab31137 ): History {
b69ab31138 const newSplitRange = extras?.splitRange ?? this.current.splitRange;
b69ab31139 const newHistory = this.history.slice(0, this.currentIndex).push(
b69ab31140 StackStateWithOperation({
b69ab31141 op,
b69ab31142 state,
b69ab31143 splitRange: newSplitRange,
b69ab31144 }),
b69ab31145 );
b69ab31146 return new History({
b69ab31147 history: newHistory,
b69ab31148 currentIndex: newHistory.size - 1,
b69ab31149 });
b69ab31150 }
b69ab31151
b69ab31152 setSplitRange(range: SplitRangeRecord): History {
b69ab31153 const newHistory = this.history.set(this.currentIndex, this.current.set('splitRange', range));
b69ab31154 return new History({
b69ab31155 history: newHistory,
b69ab31156 currentIndex: newHistory.size - 1,
b69ab31157 });
b69ab31158 }
b69ab31159
b69ab31160 canUndo(): boolean {
b69ab31161 return this.currentIndex > 0;
b69ab31162 }
b69ab31163
b69ab31164 canRedo(): boolean {
b69ab31165 return this.currentIndex + 1 < this.history.size;
b69ab31166 }
b69ab31167
b69ab31168 undoOperationDescription(): StackEditOpDescription | undefined {
b69ab31169 return this.canUndo() ? this.history.get(this.currentIndex)?.op : undefined;
b69ab31170 }
b69ab31171
b69ab31172 redoOperationDescription(): StackEditOpDescription | undefined {
b69ab31173 return this.canRedo() ? this.history.get(this.currentIndex + 1)?.op : undefined;
b69ab31174 }
b69ab31175
b69ab31176 undo(): History {
b69ab31177 return this.canUndo() ? this.set('currentIndex', this.currentIndex - 1) : this;
b69ab31178 }
b69ab31179
b69ab31180 redo(): History {
b69ab31181 return this.canRedo() ? this.set('currentIndex', this.currentIndex + 1) : this;
b69ab31182 }
b69ab31183}
b69ab31184
b69ab31185/** State related to stack editing UI. */
b69ab31186type StackEditState = {
b69ab31187 /**
b69ab31188 * Commit hashes being edited.
b69ab31189 * Empty means no editing is requested.
b69ab31190 *
b69ab31191 * Changing this to a non-empty value triggers `exportStack`
b69ab31192 * message to the server.
b69ab31193 */
b69ab31194 hashes: Set<Hash>;
b69ab31195
b69ab31196 /** Intention of the stack editing. */
b69ab31197 intention: Intention;
b69ab31198
b69ab31199 /**
b69ab31200 * The (mutable) main history of stack states.
b69ab31201 */
b69ab31202 history: Loading<History>;
b69ab31203};
b69ab31204
b69ab31205/** Lightweight recoil Loadable alternative that is not coupled with Promise. */
b69ab31206export type Loading<T> =
b69ab31207 | {
b69ab31208 state: 'loading';
b69ab31209 exportedStack:
b69ab31210 | ExportStack /* Got the exported stack. Analyzing. */
b69ab31211 | undefined /* Haven't got the exported stack. */;
b69ab31212 message?: string;
b69ab31213 }
b69ab31214 | {state: 'hasValue'; value: T}
b69ab31215 | {state: 'hasError'; error: string};
b69ab31216
b69ab31217/**
b69ab31218 * Meant to be private. Exported for debugging purpose.
b69ab31219 *
b69ab31220 * You probably want to use `useStackEditState` and other atoms instead,
b69ab31221 * which ensures consistency (ex. stack and requested hashes cannot be
b69ab31222 * out of sync).
b69ab31223 */
b69ab31224export const stackEditState = (() => {
b69ab31225 const inner = atom<StackEditState>({
b69ab31226 hashes: new Set<Hash>(),
b69ab31227 intention: 'general',
b69ab31228 history: {state: 'loading', exportedStack: undefined},
b69ab31229 });
b69ab31230 return atom<StackEditState, [StackEditState | ((s: StackEditState) => StackEditState)], void>(
b69ab31231 get => get(inner),
b69ab31232 // Kick off stack analysis on receiving an exported stack.
b69ab31233 (get, set, newValue) => {
b69ab31234 const {hashes, intention, history} =
b69ab31235 typeof newValue === 'function' ? newValue(get(inner)) : newValue;
b69ab31236 if (hashes.size > 0 && history.state === 'loading' && history.exportedStack !== undefined) {
b69ab31237 try {
b69ab31238 let stack = new CommitStackState(history.exportedStack).buildFileStacks();
b69ab31239 if (intention === 'absorb') {
b69ab31240 // Perform absorb analysis. Note: the absorb use-case has an extra
b69ab31241 // "wdir()" at the stack top for absorb purpose. When the intention
b69ab31242 // is "general" or "split", there is no "wdir()" in the stack.
b69ab31243 stack = stack.analyseAbsorb();
b69ab31244 }
b69ab31245 const historyValue = new History({
b69ab31246 history: List([StackStateWithOperation({state: stack})]),
b69ab31247 currentIndex: 0,
b69ab31248 });
b69ab31249 currentMetrics = {
b69ab31250 commits: hashes.size,
b69ab31251 fileStacks: stack.fileStacks.size,
b69ab31252 fileStackRevs: stack.fileStacks.reduce((acc, f) => acc + f.source.revLength, 0),
b69ab31253 splitFromSuggestion: currentMetrics.splitFromSuggestion,
b69ab31254 };
b69ab31255 currentMetricsStartTime = Date.now();
b69ab31256 // Cannot write to self (`stackEditState`) here.
b69ab31257 set(inner, {
b69ab31258 hashes,
b69ab31259 intention,
b69ab31260 history: {state: 'hasValue', value: historyValue},
b69ab31261 });
b69ab31262 } catch (err) {
b69ab31263 const msg = `Cannot construct stack ${err}`;
b69ab31264 set(inner, {hashes, intention, history: {state: 'hasError', error: msg}});
b69ab31265 }
b69ab31266 } else {
b69ab31267 set(inner, newValue);
b69ab31268 }
b69ab31269 },
b69ab31270 );
b69ab31271})();
b69ab31272
b69ab31273/**
b69ab31274 * Read-only access to the stack being edited.
b69ab31275 * This can be useful without going through `UseStackEditState`.
b69ab31276 * This is an atom so it can be used as a dependency of other atoms.
b69ab31277 */
b69ab31278export const stackEditStack = atom<CommitStackState | undefined>(get => {
b69ab31279 const state = get(stackEditState);
b69ab31280 return state.history.state === 'hasValue' ? state.history.value.current.state : undefined;
b69ab31281});
b69ab31282
b69ab31283// Subscribe to server exportedStack events.
b69ab31284registerDisposable(
b69ab31285 stackEditState,
b69ab31286 clientToServerAPI.onMessageOfType('exportedStack', event => {
b69ab31287 writeAtom(stackEditState, (prev): StackEditState => {
b69ab31288 const {hashes, intention} = prev;
b69ab31289 const revs = joinRevs(hashes);
b69ab31290 if (revs !== event.revs) {
b69ab31291 // Wrong stack. Ignore it.
b69ab31292 return prev;
b69ab31293 }
b69ab31294 if (event.error != null) {
b69ab31295 return {hashes, intention, history: {state: 'hasError', error: event.error}};
b69ab31296 } else {
b69ab31297 return {
b69ab31298 hashes,
b69ab31299 intention,
b69ab31300 history: {
b69ab31301 state: 'loading',
b69ab31302 exportedStack: rewriteWdirContent(rewriteCommitMessagesInStack(event.stack)),
b69ab31303 },
b69ab31304 };
b69ab31305 }
b69ab31306 });
b69ab31307 }),
b69ab31308 import.meta.hot,
b69ab31309);
b69ab31310
b69ab31311/**
b69ab31312 * Update commits messages in an exported stack to include:
b69ab31313 * 1. Any local edits the user has pending (these have already been confirmed by a modal at this point)
b69ab31314 * 2. Any remote message changes from the server (which allows the titles in the edit stack UI to be up to date)
b69ab31315 */
b69ab31316function rewriteCommitMessagesInStack(stack: ExportStack): ExportStack {
b69ab31317 const schema = readAtom(commitMessageFieldsSchema);
b69ab31318 return stack.map(c => {
b69ab31319 let text = c.text;
b69ab31320 if (schema) {
b69ab31321 const editedMessage = readAtom(latestCommitMessageFieldsWithEdits(c.node));
b69ab31322 if (editedMessage != null) {
b69ab31323 text = commitMessageFieldsToString(schema, editedMessage);
b69ab31324 }
b69ab31325 }
b69ab31326 return {...c, text};
b69ab31327 });
b69ab31328}
b69ab31329
b69ab31330/**
b69ab31331 * Update the file content of "wdir()" to match the current partial selection.
b69ab31332 * `sl` does not know the current partial selection state tracked exclusively in ISL.
b69ab31333 * So let's patch the `wdir()` commit (if exists) with the right content.
b69ab31334 */
b69ab31335function rewriteWdirContent(stack: ExportStack): ExportStack {
b69ab31336 // Run `sl debugexportstack -r "wdir()" | python3 -m json.tool` to get a sense of the `ExportStack` format.
b69ab31337 return stack.map(c => {
b69ab31338 // 'f' * 40 means the wdir() commit.
b69ab31339 if (c.node === WDIR_NODE) {
b69ab31340 const selection = readAtom(uncommittedSelection);
b69ab31341 if (c.files != null) {
b69ab31342 for (const path in c.files) {
b69ab31343 const selected = selection.getSimplifiedSelection(path);
b69ab31344 if (selected === false) {
b69ab31345 // Not selected. Drop the path.
b69ab31346 delete c.files[path];
b69ab31347 } else if (typeof selected === 'string') {
b69ab31348 // Chunk-selected. Rewrite the content.
b69ab31349 c.files[path] = {
b69ab31350 ...c.files[path],
b69ab31351 data: selected,
b69ab31352 };
b69ab31353 }
b69ab31354 }
b69ab31355 }
b69ab31356 }
b69ab31357 return c;
b69ab31358 });
b69ab31359}
b69ab31360
b69ab31361/**
b69ab31362 * Commit hashes being stack edited for general purpose.
b69ab31363 * Setting to a non-empty value (which can be using the revsetlang)
b69ab31364 * triggers server-side loading.
b69ab31365 *
b69ab31366 * For advance use-cases, the "hashes" could be revset expressions.
b69ab31367 */
b69ab31368export const editingStackIntentionHashes = atom<
b69ab31369 [Intention, Set<Hash | string>],
b69ab31370 [[Intention, Set<Hash | string>]],
b69ab31371 void
b69ab31372>(
b69ab31373 get => {
b69ab31374 const state = get(stackEditState);
b69ab31375 return [state.intention, state.hashes];
b69ab31376 },
b69ab31377 async (_get, set, newValue) => {
b69ab31378 const [intention, hashes] = newValue;
b69ab31379 const waiter = waitForNothingRunning();
b69ab31380 if (waiter != null) {
b69ab31381 set(stackEditState, {
b69ab31382 hashes,
b69ab31383 intention,
b69ab31384 history: {
b69ab31385 state: 'loading',
b69ab31386 exportedStack: undefined,
b69ab31387 message: t('Waiting for other commands to finish'),
b69ab31388 },
b69ab31389 });
b69ab31390 await waiter;
b69ab31391 }
b69ab31392 if (hashes.size > 0) {
b69ab31393 const revs = joinRevs(hashes);
b69ab31394 // Search for 'exportedStack' below for code handling the response.
b69ab31395 // For absorb's use-case, there could be untracked ('?') files that are selected.
b69ab31396 // Those would not be reported by `exportStack -r "wdir()""`. However, absorb
b69ab31397 // currently only works for edited files. So it's okay to ignore '?' selected
b69ab31398 // files by not passing `--assume-tracked FILE` to request content of these files.
b69ab31399 // In the future, we might want to make absorb support newly added files.
b69ab31400 clientToServerAPI.postMessage({type: 'exportStack', revs});
b69ab31401 }
b69ab31402 set(stackEditState, {
b69ab31403 hashes,
b69ab31404 intention,
b69ab31405 history: {state: 'loading', exportedStack: undefined},
b69ab31406 });
b69ab31407 },
b69ab31408);
b69ab31409
b69ab31410/**
b69ab31411 * State for check whether the stack is loaded or not.
b69ab31412 * Use `useStackEditState` if you want to read or edit the stack.
b69ab31413 *
b69ab31414 * This is not `Loading<CommitStackState>` so `hasValue`
b69ab31415 * states do not trigger re-render.
b69ab31416 */
b69ab31417export const loadingStackState = atom<Loading<null>>(get => {
b69ab31418 const history = get(stackEditState).history;
b69ab31419 if (history.state === 'hasValue') {
b69ab31420 return hasValueState;
b69ab31421 } else {
b69ab31422 return history;
b69ab31423 }
b69ab31424});
b69ab31425
b69ab31426const hasValueState: Loading<null> = {state: 'hasValue', value: null};
b69ab31427
b69ab31428export const shouldAutoSplitState = atom<boolean>(false);
b69ab31429
b69ab31430/** APIs exposed via useStackEditState() */
b69ab31431class UseStackEditState {
b69ab31432 state: StackEditState;
b69ab31433 setState: (_state: StackEditState) => void;
b69ab31434
b69ab31435 // derived properties.
b69ab31436 private history: History;
b69ab31437
b69ab31438 constructor(state: StackEditState, setState: (_state: StackEditState) => void) {
b69ab31439 this.state = state;
b69ab31440 this.setState = setState;
b69ab31441 assert(
b69ab31442 state.history.state === 'hasValue',
b69ab31443 'useStackEditState only works when the stack is loaded',
b69ab31444 );
b69ab31445 this.history = state.history.value;
b69ab31446 }
b69ab31447
b69ab31448 get commitStack(): CommitStackState {
b69ab31449 return this.history.current.state;
b69ab31450 }
b69ab31451
b69ab31452 get splitRange(): SplitRangeRecord {
b69ab31453 return this.history.current.splitRange;
b69ab31454 }
b69ab31455
b69ab31456 get intention(): Intention {
b69ab31457 return this.state.intention;
b69ab31458 }
b69ab31459
b69ab31460 setSplitRange(range: SplitRangeRecord | string) {
b69ab31461 const splitRange =
b69ab31462 typeof range === 'string'
b69ab31463 ? SplitRangeRecord({
b69ab31464 startKey: range,
b69ab31465 endKey: range,
b69ab31466 })
b69ab31467 : range;
b69ab31468 const newHistory = this.history.setSplitRange(splitRange);
b69ab31469 this.setHistory(newHistory);
b69ab31470 }
b69ab31471
b69ab31472 push(commitStack: CommitStackState, op: StackEditOpDescription, splitRange?: SplitRangeRecord) {
b69ab31473 if (commitStack.originalStack !== this.commitStack.originalStack) {
b69ab31474 // Wrong stack. Discard.
b69ab31475 return;
b69ab31476 }
b69ab31477 const newHistory = this.history.push(commitStack, op, {splitRange});
b69ab31478 this.setHistory(newHistory);
b69ab31479 }
b69ab31480
b69ab31481 /**
b69ab31482 * Like `pop` then `push`, used to update the most recent operation as an optimization
b69ab31483 * to avoid lots of tiny state changes in the history.
b69ab31484 */
b69ab31485 replaceTopOperation(
b69ab31486 commitStack: CommitStackState,
b69ab31487 op: StackEditOpDescription,
b69ab31488 extras?: {
b69ab31489 splitRange?: SplitRangeRecord;
b69ab31490 },
b69ab31491 ) {
b69ab31492 if (commitStack.originalStack !== this.commitStack.originalStack) {
b69ab31493 // Wrong stack. Discard.
b69ab31494 return;
b69ab31495 }
b69ab31496 const newHistory = this.history.replaceTop(commitStack, op, extras);
b69ab31497 this.setHistory(newHistory);
b69ab31498 }
b69ab31499
b69ab31500 canUndo(): boolean {
b69ab31501 return this.history.canUndo();
b69ab31502 }
b69ab31503
b69ab31504 canRedo(): boolean {
b69ab31505 return this.history.canRedo();
b69ab31506 }
b69ab31507
b69ab31508 undo() {
b69ab31509 this.setHistory(this.history.undo());
b69ab31510 }
b69ab31511
b69ab31512 undoOperationDescription(): StackEditOpDescription | undefined {
b69ab31513 return this.history.undoOperationDescription();
b69ab31514 }
b69ab31515
b69ab31516 redoOperationDescription(): StackEditOpDescription | undefined {
b69ab31517 return this.history.redoOperationDescription();
b69ab31518 }
b69ab31519
b69ab31520 redo() {
b69ab31521 this.setHistory(this.history.redo());
b69ab31522 }
b69ab31523
b69ab31524 numHistoryEditsOfType(name: StackEditOpDescription['name']): number {
b69ab31525 return this.history.history
b69ab31526 .slice(0, this.history.currentIndex + 1)
b69ab31527 .filter(s => s.op.name === name).size;
b69ab31528 }
b69ab31529
b69ab31530 /**
b69ab31531 * Count edits made after an AI split operation.
b69ab31532 * This helps measure the edit rate - how often users modify AI suggestions.
b69ab31533 * Returns the count of non-AI-split operations that occur after any splitWithAI operation.
b69ab31534 */
b69ab31535 countEditsAfterAiSplit(): number {
b69ab31536 const historySlice = this.history.history.slice(0, this.history.currentIndex + 1);
b69ab31537 let foundAiSplit = false;
b69ab31538 let editsAfterAiSplit = 0;
b69ab31539
b69ab31540 for (const entry of historySlice) {
b69ab31541 if (entry.op.name === 'splitWithAI') {
b69ab31542 foundAiSplit = true;
b69ab31543 } else if (foundAiSplit && entry.op.name !== 'import') {
b69ab31544 // Count any non-import operations after an AI split
b69ab31545 // Exclude 'import' as it's the initial state operation
b69ab31546 editsAfterAiSplit++;
b69ab31547 }
b69ab31548 }
b69ab31549
b69ab31550 return editsAfterAiSplit;
b69ab31551 }
b69ab31552
b69ab31553 private setHistory(newHistory: History) {
b69ab31554 const {hashes, intention} = this.state;
b69ab31555 this.setState({
b69ab31556 hashes,
b69ab31557 intention,
b69ab31558 history: {state: 'hasValue', value: newHistory},
b69ab31559 });
b69ab31560 }
b69ab31561}
b69ab31562
b69ab31563// Only export the type, not the constructor.
b69ab31564export type {UseStackEditState};
b69ab31565
b69ab31566/**
b69ab31567 * Get the stack edit state. The stack must be loaded already, that is,
b69ab31568 * `loadingStackState.state` is `hasValue`. This is the main state for
b69ab31569 * reading and updating the `CommitStackState`.
b69ab31570 */
b69ab31571// This is not a recoil selector for flexibility.
b69ab31572// See https://github.com/facebookexperimental/Recoil/issues/673
b69ab31573export function useStackEditState() {
b69ab31574 const [state, setState] = useAtom(stackEditState);
b69ab31575 return new UseStackEditState(state, setState);
b69ab31576}
b69ab31577
b69ab31578/** Get revset expression for requested hashes. */
b69ab31579function joinRevs(hashes: Set<Hash>): string {
b69ab31580 return [...hashes].join('|');
b69ab31581}
b69ab31582
b69ab31583type StackEditMetrics = {
b69ab31584 // Managed by this file.
b69ab31585 commits: number;
b69ab31586 fileStacks: number;
b69ab31587 fileStackRevs: number;
b69ab31588 acceptedAiSplits?: number;
b69ab31589 // Maintained by UI, via 'bumpStackEditMetric'.
b69ab31590 undo?: number;
b69ab31591 redo?: number;
b69ab31592 fold?: number;
b69ab31593 drop?: number;
b69ab31594 moveUpDown?: number;
b69ab31595 swapLeftRight?: number;
b69ab31596 moveDnD?: number;
b69ab31597 fileStackEdit?: number;
b69ab31598 splitMoveFile?: number;
b69ab31599 splitMoveLine?: number;
b69ab31600 splitInsertBlank?: number;
b69ab31601 splitChangeRange?: number;
b69ab31602 splitFromSuggestion?: number;
b69ab31603 clickedAiSplit?: number;
b69ab31604 // Devmate split specific metrics for acceptance rate tracking
b69ab31605 clickedDevmateSplit?: number;
b69ab31606 // Track edits made after an AI split was applied (to measure edit rate)
b69ab31607 editsAfterAiSplit?: number;
b69ab31608};
b69ab31609
b69ab31610// Not atoms. They do not trigger re-render.
b69ab31611let currentMetrics: StackEditMetrics = {commits: 0, fileStackRevs: 0, fileStacks: 0};
b69ab31612let currentMetricsStartTime = 0;
b69ab31613
b69ab31614export function bumpStackEditMetric(key: keyof StackEditMetrics, count = 1) {
b69ab31615 currentMetrics[key] = (currentMetrics[key] ?? 0) + count;
b69ab31616}
b69ab31617
b69ab31618export function sendStackEditMetrics(stackEdit: UseStackEditState, save = true) {
b69ab31619 const tracker = getTracker();
b69ab31620 const duration = Date.now() - currentMetricsStartTime;
b69ab31621 const intention = readAtom(stackEditState).intention;
b69ab31622
b69ab31623 // # accepted AI splits is how many AI split operations are remaining at the end
b69ab31624 const numAiSplits = stackEdit.numHistoryEditsOfType('splitWithAI');
b69ab31625 if (numAiSplits) {
b69ab31626 bumpStackEditMetric('acceptedAiSplits', numAiSplits);
b69ab31627 }
b69ab31628
b69ab31629 // Count edits made after AI splits (to measure edit rate)
b69ab31630 // This counts any non-AI-split operations that occurred after a splitWithAI
b69ab31631 const editsAfterAiSplit = stackEdit.countEditsAfterAiSplit();
b69ab31632 if (editsAfterAiSplit > 0) {
b69ab31633 bumpStackEditMetric('editsAfterAiSplit', editsAfterAiSplit);
b69ab31634 }
b69ab31635
b69ab31636 tracker?.track('StackEditMetrics', {
b69ab31637 duration,
b69ab31638 extras: {...currentMetrics, save, intention},
b69ab31639 });
b69ab31640 currentMetrics.splitFromSuggestion = 0; // Reset for next time.
b69ab31641}
b69ab31642
b69ab31643export {WDIR_NODE};
b69ab31644
b69ab31645export function findStartEndRevs(
b69ab31646 stackEdit: UseStackEditState,
b69ab31647): [CommitRev | undefined, CommitRev | undefined] {
b69ab31648 const {splitRange, intention, commitStack} = stackEdit;
b69ab31649 if (intention === 'split') {
b69ab31650 return [1 as CommitRev, prev(commitStack.size as CommitRev)];
b69ab31651 }
b69ab31652 const startRev = commitStack.findCommitByKey(splitRange.startKey)?.rev;
b69ab31653 let endRev = commitStack.findCommitByKey(splitRange.endKey)?.rev;
b69ab31654 if (startRev == null || startRev > (endRev ?? -1)) {
b69ab31655 endRev = undefined;
b69ab31656 }
b69ab31657 return [startRev, endRev];
b69ab31658}