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