| 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 | |
| 8 | import type {RecordOf} from 'immutable'; |
| 9 | import type {ExportStack} from 'shared/types/stack'; |
| 10 | import type {Hash} from '../../types'; |
| 11 | import type {CommitRev, CommitState} from '../commitStackState'; |
| 12 | |
| 13 | import {List, Record} from 'immutable'; |
| 14 | import {atom, useAtom} from 'jotai'; |
| 15 | import {nullthrows} from 'shared/utils'; |
| 16 | import clientToServerAPI from '../../ClientToServerAPI'; |
| 17 | import {latestCommitMessageFieldsWithEdits} from '../../CommitInfoView/CommitInfoState'; |
| 18 | import { |
| 19 | commitMessageFieldsSchema, |
| 20 | commitMessageFieldsToString, |
| 21 | } from '../../CommitInfoView/CommitMessageFields'; |
| 22 | import {getTracker} from '../../analytics/globalTracker'; |
| 23 | import {WDIR_NODE} from '../../dag/virtualCommit'; |
| 24 | import {t} from '../../i18n'; |
| 25 | import {readAtom, writeAtom} from '../../jotaiUtils'; |
| 26 | import {waitForNothingRunning} from '../../operationsState'; |
| 27 | import {uncommittedSelection} from '../../partialSelection'; |
| 28 | import {CommitStackState} from '../../stackEdit/commitStackState'; |
| 29 | import {assert, registerDisposable} from '../../utils'; |
| 30 | import {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 | */ |
| 37 | type 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 | |
| 45 | type Intention = 'general' | 'split' | 'absorb'; |
| 46 | |
| 47 | /** Description of a stack edit operation. Used for display purpose. */ |
| 48 | export 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 | |
| 75 | type SplitRangeProps = { |
| 76 | startKey: string; |
| 77 | endKey: string; |
| 78 | }; |
| 79 | export const SplitRangeRecord = Record<SplitRangeProps>({startKey: '', endKey: ''}); |
| 80 | export type SplitRangeRecord = RecordOf<SplitRangeProps>; |
| 81 | |
| 82 | // See `StackStateWithOperationProps`. |
| 83 | const StackStateWithOperation = Record<StackStateWithOperationProps>({ |
| 84 | op: {name: 'import'}, |
| 85 | state: new CommitStackState([]), |
| 86 | splitRange: SplitRangeRecord(), |
| 87 | }); |
| 88 | type StackStateWithOperation = RecordOf<StackStateWithOperationProps>; |
| 89 | |
| 90 | /** History of multiple states for undo/redo support. */ |
| 91 | type HistoryProps = { |
| 92 | history: List<StackStateWithOperation>; |
| 93 | currentIndex: number; |
| 94 | }; |
| 95 | |
| 96 | const HistoryRecord = Record<HistoryProps>({ |
| 97 | history: List(), |
| 98 | currentIndex: 0, |
| 99 | }); |
| 100 | type HistoryRecord = RecordOf<HistoryProps>; |
| 101 | |
| 102 | class 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. */ |
| 186 | type 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. */ |
| 206 | export 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 | */ |
| 224 | export 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 | */ |
| 278 | export 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. |
| 284 | registerDisposable( |
| 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 | */ |
| 316 | function 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 | */ |
| 335 | function 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 | */ |
| 368 | export 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 | */ |
| 417 | export 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 | |
| 426 | const hasValueState: Loading<null> = {state: 'hasValue', value: null}; |
| 427 | |
| 428 | export const shouldAutoSplitState = atom<boolean>(false); |
| 429 | |
| 430 | /** APIs exposed via useStackEditState() */ |
| 431 | class 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. |
| 564 | export 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 |
| 573 | export function useStackEditState() { |
| 574 | const [state, setState] = useAtom(stackEditState); |
| 575 | return new UseStackEditState(state, setState); |
| 576 | } |
| 577 | |
| 578 | /** Get revset expression for requested hashes. */ |
| 579 | function joinRevs(hashes: Set<Hash>): string { |
| 580 | return [...hashes].join('|'); |
| 581 | } |
| 582 | |
| 583 | type 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. |
| 611 | let currentMetrics: StackEditMetrics = {commits: 0, fileStackRevs: 0, fileStacks: 0}; |
| 612 | let currentMetricsStartTime = 0; |
| 613 | |
| 614 | export function bumpStackEditMetric(key: keyof StackEditMetrics, count = 1) { |
| 615 | currentMetrics[key] = (currentMetrics[key] ?? 0) + count; |
| 616 | } |
| 617 | |
| 618 | export 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 | |
| 643 | export {WDIR_NODE}; |
| 644 | |
| 645 | export 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 | |