| 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 {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether'; |
| 9 | import type {RepoPath} from 'shared/types/common'; |
| 10 | import type {CommitMessageFields} from '../../CommitInfoView/types'; |
| 11 | import type {CommitRev, CommitStackState, FileMetadata, FileStackIndex} from '../commitStackState'; |
| 12 | import type {FileRev, FileStackState} from '../fileStackState'; |
| 13 | import type {UseStackEditState} from './stackEditState'; |
| 14 | |
| 15 | import * as stylex from '@stylexjs/stylex'; |
| 16 | import {Set as ImSet, type List, Range} from 'immutable'; |
| 17 | import {Button} from 'isl-components/Button'; |
| 18 | import {Icon} from 'isl-components/Icon'; |
| 19 | import {Subtle} from 'isl-components/Subtle'; |
| 20 | import {TextField} from 'isl-components/TextField'; |
| 21 | import {Tooltip} from 'isl-components/Tooltip'; |
| 22 | import {useAtom, useAtomValue} from 'jotai'; |
| 23 | import {useEffect, useMemo, useRef, useState} from 'react'; |
| 24 | import {useContextMenu} from 'shared/ContextMenu'; |
| 25 | import {readableDiffBlocks as diffBlocks, type LineIdx, splitLines} from 'shared/diff'; |
| 26 | import {useThrottledEffect} from 'shared/hooks'; |
| 27 | import {firstLine, nullthrows} from 'shared/utils'; |
| 28 | import {BranchIndicator} from '../../BranchIndicator'; |
| 29 | import {commitMessageTemplate} from '../../CommitInfoView/CommitInfoState'; |
| 30 | import { |
| 31 | commitMessageFieldsSchema, |
| 32 | commitMessageFieldsToString, |
| 33 | } from '../../CommitInfoView/CommitMessageFields'; |
| 34 | import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader'; |
| 35 | import {useTokenizedContentsOnceVisible} from '../../ComparisonView/SplitDiffView/syntaxHighlighting'; |
| 36 | import {Column, Row, ScrollX, ScrollY} from '../../ComponentUtils'; |
| 37 | import {EmptyState} from '../../EmptyState'; |
| 38 | import {useGeneratedFileStatuses} from '../../GeneratedFile'; |
| 39 | import {tracker} from '../../analytics'; |
| 40 | import {t, T} from '../../i18n'; |
| 41 | import {readAtom} from '../../jotaiUtils'; |
| 42 | import {themeState} from '../../theme'; |
| 43 | import {GeneratedStatus} from '../../types'; |
| 44 | import {isAbsent, reorderedRevs} from '../commitStackState'; |
| 45 | import {max, next, prev} from '../revMath'; |
| 46 | import {AISplitButton} from './AISplit'; |
| 47 | import {computeLinesForFileStackEditor} from './FileStackEditorLines'; |
| 48 | import { |
| 49 | bumpStackEditMetric, |
| 50 | findStartEndRevs, |
| 51 | shouldAutoSplitState, |
| 52 | SplitRangeRecord, |
| 53 | useStackEditState, |
| 54 | } from './stackEditState'; |
| 55 | |
| 56 | import './SplitStackEditPanel.css'; |
| 57 | |
| 58 | const styles = stylex.create({ |
| 59 | full: { |
| 60 | width: '100%', |
| 61 | }, |
| 62 | }); |
| 63 | |
| 64 | export function SplitStackEditPanel() { |
| 65 | const stackEdit = useStackEditState(); |
| 66 | |
| 67 | const {commitStack} = stackEdit; |
| 68 | |
| 69 | const messageTemplate = useAtomValue(commitMessageTemplate); |
| 70 | const schema = useAtomValue(commitMessageFieldsSchema); |
| 71 | |
| 72 | // Find the commits being split. |
| 73 | const [startRev, endRev] = findStartEndRevs(stackEdit); |
| 74 | |
| 75 | // Nothing to split? Show a dropdown. |
| 76 | if (startRev == null || endRev == null || startRev > endRev) { |
| 77 | return ( |
| 78 | <div> |
| 79 | <EmptyState small> |
| 80 | <T>Select a commit to split its changes.</T> |
| 81 | <br /> |
| 82 | <Subtle> |
| 83 | <T>Or, select a range of commits to move contents among them.</T> |
| 84 | </Subtle> |
| 85 | </EmptyState> |
| 86 | </div> |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | // Prepare a "dense" subStack with an extra empty commit to move right. |
| 91 | const emptyTitle = getEmptyCommitTitle(commitStack.get(endRev)?.text ?? ''); |
| 92 | const fields: CommitMessageFields = {...messageTemplate, Title: emptyTitle}; |
| 93 | const message = commitMessageFieldsToString(schema, fields); |
| 94 | const subStack = commitStack |
| 95 | .insertEmpty(next(endRev), message, endRev) |
| 96 | .denseSubStack(Range(startRev, endRev + 2).toList() as List<CommitRev>); |
| 97 | |
| 98 | const insertBlankCommit = (rev: CommitRev) => { |
| 99 | const fields: CommitMessageFields = {...messageTemplate, Title: t('New Commit')}; |
| 100 | const message = commitMessageFieldsToString(schema, fields); |
| 101 | |
| 102 | const newStack = stackEdit.commitStack.insertEmpty((startRev + rev) as CommitRev, message); |
| 103 | |
| 104 | bumpStackEditMetric('splitInsertBlank'); |
| 105 | |
| 106 | let {splitRange} = stackEdit; |
| 107 | if (rev === 0) { |
| 108 | const newStart = newStack.get(startRev); |
| 109 | if (newStart != null) { |
| 110 | splitRange = splitRange.set('startKey', newStart.key); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | stackEdit.push(newStack, {name: 'insertBlankCommit'}, splitRange); |
| 115 | }; |
| 116 | |
| 117 | // One commit per column. |
| 118 | const columns: JSX.Element[] = subStack |
| 119 | .revs() |
| 120 | .map(rev => ( |
| 121 | <SplitColumn |
| 122 | stackEdit={stackEdit} |
| 123 | commitStack={commitStack} |
| 124 | key={rev} |
| 125 | rev={rev} |
| 126 | subStack={subStack} |
| 127 | insertBlankCommit={insertBlankCommit} |
| 128 | /> |
| 129 | )); |
| 130 | |
| 131 | return ( |
| 132 | <div className="interactive-split"> |
| 133 | <ScrollX maxSize="calc((100vw / var(--zoom)) - 30px)"> |
| 134 | <Row style={{padding: '0 var(--pad)', alignItems: 'flex-start'}}>{columns}</Row> |
| 135 | </ScrollX> |
| 136 | </div> |
| 137 | ); |
| 138 | } |
| 139 | |
| 140 | type SplitColumnProps = { |
| 141 | stackEdit: UseStackEditState; |
| 142 | commitStack: CommitStackState; |
| 143 | subStack: CommitStackState; |
| 144 | rev: CommitRev; |
| 145 | insertBlankCommit: (rev: CommitRev) => unknown; |
| 146 | }; |
| 147 | |
| 148 | function InsertBlankCommitButton({ |
| 149 | beforeRev, |
| 150 | onClick, |
| 151 | }: { |
| 152 | beforeRev: CommitRev | undefined; |
| 153 | onClick: () => unknown; |
| 154 | }) { |
| 155 | return ( |
| 156 | <div className="split-insert-blank-commit-container" role="button" onClick={onClick}> |
| 157 | <Tooltip |
| 158 | placement="top" |
| 159 | title={ |
| 160 | beforeRev == 0 |
| 161 | ? t('Insert a new blank commit before the next commit') |
| 162 | : t('Insert a new blank commit between these commits') |
| 163 | }> |
| 164 | <div className="split-insert-blank-commit"> |
| 165 | <Icon icon="add" /> |
| 166 | </div> |
| 167 | </Tooltip> |
| 168 | </div> |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | function SwapCommitsButton({ |
| 173 | stackEdit, |
| 174 | beforeRev, |
| 175 | }: { |
| 176 | stackEdit: UseStackEditState; |
| 177 | beforeRev: CommitRev | undefined; |
| 178 | }) { |
| 179 | if (beforeRev == null || beforeRev === 0) { |
| 180 | return null; |
| 181 | } |
| 182 | const state = stackEdit.commitStack; |
| 183 | const beforeRevCommit = state.get(beforeRev); |
| 184 | if (beforeRevCommit == null) { |
| 185 | return null; |
| 186 | } |
| 187 | const newOrder = reorderedRevs(state, beforeRev); |
| 188 | const canSwap = state.canReorder(newOrder); |
| 189 | if (!canSwap) { |
| 190 | return null; |
| 191 | } |
| 192 | return ( |
| 193 | <div |
| 194 | className="split-insert-blank-commit-container" |
| 195 | role="button" |
| 196 | onClick={() => { |
| 197 | stackEdit.push(state.reorder(newOrder), { |
| 198 | name: 'swap', |
| 199 | }); |
| 200 | bumpStackEditMetric('swapLeftRight'); |
| 201 | }}> |
| 202 | <Tooltip placement="top" title={t('Swap the order of two commits.')}> |
| 203 | <div className="split-insert-blank-commit"> |
| 204 | <Icon icon="arrow-swap" /> |
| 205 | </div> |
| 206 | </Tooltip> |
| 207 | </div> |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | function SplitColumn(props: SplitColumnProps) { |
| 212 | const {stackEdit, commitStack, subStack, rev, insertBlankCommit} = props; |
| 213 | |
| 214 | const [collapsedFiles, setCollapsedFiles] = useState(new Set()); |
| 215 | |
| 216 | const toggleCollapsed = (path: RepoPath) => { |
| 217 | const updated = new Set(collapsedFiles); |
| 218 | updated.has(path) ? updated.delete(path) : updated.add(path); |
| 219 | setCollapsedFiles(updated); |
| 220 | }; |
| 221 | |
| 222 | const commit = subStack.get(rev); |
| 223 | const commitMessage = commit?.text ?? ''; |
| 224 | |
| 225 | // File stacks contain text (content-editable) files. |
| 226 | // Note: subStack might contain files that are not editable |
| 227 | // (ex. currently binary, but previously absent). Filter them out. |
| 228 | const editablePaths = subStack.getPaths(rev, {text: true}); |
| 229 | const editablePathsSet = new Set(editablePaths); |
| 230 | const generatedStatuses = useGeneratedFileStatuses(editablePaths); |
| 231 | const sortedFileStacks = subStack.fileStacks |
| 232 | .flatMap((fileStack, fileIdx): Array<[RepoPath, FileStackState, FileStackIndex]> => { |
| 233 | const path = subStack.getFileStackPath(fileIdx, 0 as FileRev) ?? ''; |
| 234 | return editablePathsSet.has(path) ? [[path, fileStack, fileIdx]] : []; |
| 235 | }) |
| 236 | .sort((a, b) => { |
| 237 | const [pathA] = a; |
| 238 | const [pathB] = b; |
| 239 | |
| 240 | const statusA = generatedStatuses[pathA] ?? GeneratedStatus.Manual; |
| 241 | const statusB = generatedStatuses[pathB] ?? GeneratedStatus.Manual; |
| 242 | |
| 243 | return statusA === statusB ? pathA.localeCompare(pathB) : statusA - statusB; |
| 244 | }); |
| 245 | |
| 246 | // There might be non-text (ex. binary, or too large) files. |
| 247 | const nonEditablePaths = subStack.getPaths(rev, {text: false}).sort(); |
| 248 | |
| 249 | const editables = sortedFileStacks.flatMap(([path, fileStack, fileIdx]) => { |
| 250 | // subStack is a "dense" stack. fileRev is commitRev + 1. |
| 251 | const fileRev = (rev + 1) as FileRev; |
| 252 | const isModified = |
| 253 | (fileRev > 0 && fileStack.getRev(prev(fileRev)) !== fileStack.getRev(fileRev)) || |
| 254 | subStack.changedFileMetadata(rev, path) != null; |
| 255 | const editor = ( |
| 256 | <SplitEditorWithTitle |
| 257 | key={path} |
| 258 | subStack={subStack} |
| 259 | rev={rev} |
| 260 | path={path} |
| 261 | fileStack={fileStack} |
| 262 | fileIdx={fileIdx} |
| 263 | fileRev={fileRev} |
| 264 | collapsed={collapsedFiles.has(path)} |
| 265 | toggleCollapsed={() => toggleCollapsed(path)} |
| 266 | generatedStatus={generatedStatuses[path]} |
| 267 | /> |
| 268 | ); |
| 269 | const result = isModified ? [editor] : []; |
| 270 | return result; |
| 271 | }); |
| 272 | |
| 273 | const nonEditables = nonEditablePaths.flatMap(path => { |
| 274 | const file = subStack.getFile(rev, path); |
| 275 | const prevFile = subStack.getFile(prev(rev), path); |
| 276 | const isModified = !file.equals(prevFile); |
| 277 | if (!isModified) { |
| 278 | return []; |
| 279 | } |
| 280 | const editor = ( |
| 281 | <SplitEditorWithTitle |
| 282 | key={path} |
| 283 | subStack={subStack} |
| 284 | rev={rev} |
| 285 | path={path} |
| 286 | collapsed={collapsedFiles.has(path)} |
| 287 | toggleCollapsed={() => toggleCollapsed(path)} |
| 288 | /> |
| 289 | ); |
| 290 | return [editor]; |
| 291 | }); |
| 292 | |
| 293 | const editors = editables.concat(nonEditables); |
| 294 | |
| 295 | const body = editors.isEmpty() ? ( |
| 296 | <EmptyState small> |
| 297 | <Column> |
| 298 | <T>This commit is empty</T> |
| 299 | <Subtle> |
| 300 | <T>Use the left/right arrows to move files and lines of code and create new commits.</T> |
| 301 | </Subtle> |
| 302 | </Column> |
| 303 | </EmptyState> |
| 304 | ) : ( |
| 305 | <ScrollY maxSize="calc((100vh / var(--zoom)) - var(--split-vertical-overhead))" hideBar={true}> |
| 306 | {editors} |
| 307 | </ScrollY> |
| 308 | ); |
| 309 | |
| 310 | const showExtraCommitActionsContextMenu = useContextMenu(() => { |
| 311 | const options = []; |
| 312 | const allFiles = new Set(sortedFileStacks.map(([path]) => path)); |
| 313 | if (collapsedFiles.size < allFiles.size && allFiles.size > 0) { |
| 314 | options.push({ |
| 315 | label: t('Collapse all files'), |
| 316 | onClick() { |
| 317 | setCollapsedFiles(allFiles); |
| 318 | }, |
| 319 | }); |
| 320 | } |
| 321 | if (collapsedFiles.size > 0) { |
| 322 | options.push({ |
| 323 | label: t('Expand all files'), |
| 324 | onClick() { |
| 325 | setCollapsedFiles(new Set()); |
| 326 | }, |
| 327 | }); |
| 328 | } |
| 329 | return options; |
| 330 | }); |
| 331 | |
| 332 | const [shouldAutoSplit, setShouldAutoSplit] = useAtom(shouldAutoSplitState); |
| 333 | const aiSplitButtonRef = useRef<HTMLButtonElement | null>(null); |
| 334 | |
| 335 | useEffect(() => { |
| 336 | const autoTriggerAISplit = () => { |
| 337 | if (aiSplitButtonRef.current != null) { |
| 338 | aiSplitButtonRef.current.click(); |
| 339 | } |
| 340 | }; |
| 341 | |
| 342 | if (shouldAutoSplit) { |
| 343 | setShouldAutoSplit(false); |
| 344 | autoTriggerAISplit(); |
| 345 | } |
| 346 | }, [setShouldAutoSplit, shouldAutoSplit]); |
| 347 | |
| 348 | return ( |
| 349 | <> |
| 350 | {editors.isEmpty() ? null : ( |
| 351 | <Column> |
| 352 | <InsertBlankCommitButton beforeRev={rev} onClick={() => insertBlankCommit(rev)} /> |
| 353 | <SwapCommitsButton stackEdit={stackEdit} beforeRev={rev} /> |
| 354 | </Column> |
| 355 | )} |
| 356 | <div className="split-commit-column"> |
| 357 | <div className="split-commit-header"> |
| 358 | <span className="split-commit-header-stack-number"> |
| 359 | {rev + 1} / {subStack.size} |
| 360 | </span> |
| 361 | <EditableCommitTitle commitMessage={commitMessage} commitKey={commit?.key} /> |
| 362 | <AISplitButton |
| 363 | stackEdit={stackEdit} |
| 364 | commitStack={commitStack} |
| 365 | subStack={subStack} |
| 366 | rev={rev} |
| 367 | ref={aiSplitButtonRef} |
| 368 | /> |
| 369 | <Button icon onClick={e => showExtraCommitActionsContextMenu(e)}> |
| 370 | <Icon icon="ellipsis" /> |
| 371 | </Button> |
| 372 | </div> |
| 373 | {body} |
| 374 | </div> |
| 375 | </> |
| 376 | ); |
| 377 | } |
| 378 | |
| 379 | type SplitEditorWithTitleProps = { |
| 380 | subStack: CommitStackState; |
| 381 | rev: CommitRev; |
| 382 | path: RepoPath; |
| 383 | fileStack?: FileStackState; |
| 384 | fileIdx?: number; |
| 385 | fileRev?: FileRev; |
| 386 | collapsed: boolean; |
| 387 | toggleCollapsed: () => unknown; |
| 388 | generatedStatus?: GeneratedStatus; |
| 389 | }; |
| 390 | |
| 391 | function SplitEditorWithTitle(props: SplitEditorWithTitleProps) { |
| 392 | const stackEdit = useStackEditState(); |
| 393 | |
| 394 | const {commitStack} = stackEdit; |
| 395 | const { |
| 396 | subStack, |
| 397 | path, |
| 398 | fileStack, |
| 399 | fileIdx, |
| 400 | fileRev, |
| 401 | collapsed, |
| 402 | toggleCollapsed, |
| 403 | rev, |
| 404 | generatedStatus, |
| 405 | } = props; |
| 406 | const file = subStack.getFile(rev, path); |
| 407 | const [showGeneratedFileAnyway, setShowGeneratedFileAnyway] = useState(false); |
| 408 | |
| 409 | const setSubStack = (newSubStack: CommitStackState) => { |
| 410 | const [startRev, endRev] = findStartEndRevs(stackEdit); |
| 411 | if (startRev != null && endRev != null) { |
| 412 | const newCommitStack = commitStack.applySubStack(startRev, next(endRev), newSubStack); |
| 413 | // Find the new split range. |
| 414 | const endOffset = newCommitStack.size - commitStack.size; |
| 415 | const startKey = newCommitStack.get(startRev)?.key ?? ''; |
| 416 | const endKey = newCommitStack.get(next(endRev, endOffset))?.key ?? ''; |
| 417 | const splitRange = SplitRangeRecord({startKey, endKey}); |
| 418 | // Update the main stack state. |
| 419 | stackEdit.push(newCommitStack, {name: 'split', path}, splitRange); |
| 420 | } |
| 421 | }; |
| 422 | |
| 423 | const setStack = (newFileStack: FileStackState) => { |
| 424 | if (fileIdx == null || fileRev == null) { |
| 425 | return; |
| 426 | } |
| 427 | const newSubStack = subStack.setFileStack(fileIdx, newFileStack); |
| 428 | setSubStack(newSubStack); |
| 429 | }; |
| 430 | |
| 431 | const moveEntireFile = (dir: 'left' | 'right') => { |
| 432 | // Suppose the file has 5 versions, and current version is 'v3': |
| 433 | // v1--v2--v3--v4--v5 |
| 434 | // Move left: |
| 435 | // v1--v3--v3--v4--v5 (replace v2 with v3) |
| 436 | // If v3 has 'copyFrom', drop 'copyFrom' on the second 'v3'. |
| 437 | // If v2 had 'copyFrom', preserve it on the first 'v3'. |
| 438 | // Move right: |
| 439 | // v1--v2--v2--v4--v5 (replace v3 with v2) |
| 440 | // If v3 has 'copyFrom', update 'copyFrom' on 'v4'. |
| 441 | // v4 should not have 'copyFrom'. |
| 442 | const [fromRev, toRev] = dir === 'left' ? [rev, prev(rev)] : [prev(rev), rev]; |
| 443 | const fromFile = subStack.getFile(fromRev, path); |
| 444 | let newStack = subStack.setFile(toRev, path, oldFile => { |
| 445 | if (dir === 'left' && oldFile.copyFrom != null) { |
| 446 | return fromFile.set('copyFrom', oldFile.copyFrom); |
| 447 | } |
| 448 | return fromFile; |
| 449 | }); |
| 450 | if (file.copyFrom != null) { |
| 451 | if (dir === 'right') { |
| 452 | newStack = newStack.setFile(next(rev), path, f => f.set('copyFrom', file.copyFrom)); |
| 453 | } else { |
| 454 | newStack = newStack.setFile(rev, path, f => f.remove('copyFrom')); |
| 455 | } |
| 456 | } |
| 457 | bumpStackEditMetric('splitMoveFile'); |
| 458 | setSubStack(newStack); |
| 459 | }; |
| 460 | |
| 461 | const changedMeta = subStack.changedFileMetadata(rev, path, false); |
| 462 | let iconType = IconType.Modified; |
| 463 | if (changedMeta != null) { |
| 464 | const [oldMeta, newMeta] = changedMeta; |
| 465 | if (isAbsent(oldMeta) && !isAbsent(newMeta)) { |
| 466 | iconType = IconType.Added; |
| 467 | } else if (!isAbsent(oldMeta) && isAbsent(newMeta)) { |
| 468 | iconType = IconType.Removed; |
| 469 | } |
| 470 | } |
| 471 | const canMoveLeft = |
| 472 | rev > 0 && (file.copyFrom == null || isAbsent(subStack.getFile(prev(rev), path))); |
| 473 | let copyFromText = undefined; |
| 474 | if (file.copyFrom != null) { |
| 475 | const copyFromFile = subStack.getFile(prev(rev), file.copyFrom); |
| 476 | try { |
| 477 | // This will throw if copyFromFile is non-text (binary, or too large). |
| 478 | copyFromText = subStack.getUtf8Data(copyFromFile); |
| 479 | } catch {} |
| 480 | } |
| 481 | |
| 482 | return ( |
| 483 | <div className="split-commit-file"> |
| 484 | <FileHeader |
| 485 | path={path} |
| 486 | copyFrom={file.copyFrom} |
| 487 | iconType={iconType} |
| 488 | open={!collapsed} |
| 489 | onChangeOpen={toggleCollapsed} |
| 490 | fileActions={ |
| 491 | <div className="split-commit-file-arrows"> |
| 492 | {canMoveLeft ? ( |
| 493 | <Button icon onClick={() => moveEntireFile('left')}> |
| 494 | ⬅ |
| 495 | </Button> |
| 496 | ) : null} |
| 497 | <Button icon onClick={() => moveEntireFile('right')}> |
| 498 | ⮕ |
| 499 | </Button> |
| 500 | </div> |
| 501 | } |
| 502 | /> |
| 503 | {!collapsed && ( |
| 504 | <> |
| 505 | <ModeChangeHints changedMeta={changedMeta} /> |
| 506 | {fileRev != null && fileStack != null ? ( |
| 507 | !showGeneratedFileAnyway && generatedStatus !== GeneratedStatus.Manual ? ( |
| 508 | <Generated onShowAnyway={setShowGeneratedFileAnyway} /> |
| 509 | ) : ( |
| 510 | <SplitFile |
| 511 | key={fileIdx} |
| 512 | rev={fileRev} |
| 513 | stack={fileStack} |
| 514 | setStack={setStack} |
| 515 | path={path} |
| 516 | copyFromText={copyFromText} |
| 517 | /> |
| 518 | ) |
| 519 | ) : ( |
| 520 | <NonEditable /> |
| 521 | )} |
| 522 | </> |
| 523 | )} |
| 524 | </div> |
| 525 | ); |
| 526 | } |
| 527 | |
| 528 | const FLAG_TO_MESSAGE = new Map<string, string>([ |
| 529 | ['', t('regular')], |
| 530 | ['l', t('symlink')], |
| 531 | ['x', t('executable')], |
| 532 | ['m', t('Git submodule')], |
| 533 | ]); |
| 534 | |
| 535 | function ModeChangeHints(props: {changedMeta?: [FileMetadata, FileMetadata]}) { |
| 536 | const {changedMeta} = props; |
| 537 | if (changedMeta == null) { |
| 538 | return null; |
| 539 | } |
| 540 | |
| 541 | const [oldMeta, newMeta] = changedMeta; |
| 542 | const oldFlag = oldMeta.flags ?? ''; |
| 543 | const newFlag = newMeta.flags ?? ''; |
| 544 | let message = null; |
| 545 | |
| 546 | if (!isAbsent(newMeta)) { |
| 547 | const newDesc = FLAG_TO_MESSAGE.get(newFlag); |
| 548 | // Show hint for newly added non-regular files. |
| 549 | if (newFlag !== '' && isAbsent(oldMeta)) { |
| 550 | if (newDesc != null) { |
| 551 | message = t('File type: $new', {replace: {$new: newDesc}}); |
| 552 | } |
| 553 | } else { |
| 554 | // Show hint when the flag (mode) has changed. |
| 555 | if (newFlag !== oldFlag) { |
| 556 | const oldDesc = FLAG_TO_MESSAGE.get(oldFlag); |
| 557 | if (oldDesc != null && newDesc != null && oldDesc !== newDesc) { |
| 558 | message = t('File type change: $old → $new', {replace: {$old: oldDesc, $new: newDesc}}); |
| 559 | } |
| 560 | } |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | return message == null ? null : <div className="split-header-hint">{message}</div>; |
| 565 | } |
| 566 | |
| 567 | function NonEditable() { |
| 568 | return ( |
| 569 | <div className="split-header-hint"> |
| 570 | <T>Binary or large file content is not editable.</T> |
| 571 | </div> |
| 572 | ); |
| 573 | } |
| 574 | |
| 575 | function Generated({onShowAnyway}: {onShowAnyway: (show: boolean) => void}) { |
| 576 | return ( |
| 577 | <div className="split-header-hint"> |
| 578 | <Column> |
| 579 | <T>This file is generated</T> |
| 580 | <Button icon onClick={() => onShowAnyway(true)}> |
| 581 | <T>Show anyway</T> |
| 582 | </Button> |
| 583 | </Column> |
| 584 | </div> |
| 585 | ); |
| 586 | } |
| 587 | |
| 588 | /** Open dialog to select a commit range to split. */ |
| 589 | function StackRangeSelectorButton() { |
| 590 | const stackEdit = useStackEditState(); |
| 591 | |
| 592 | const [startRev, endRev] = findStartEndRevs(stackEdit); |
| 593 | const {commitStack} = stackEdit; |
| 594 | const startCommit = startRev == null ? null : commitStack.get(startRev); |
| 595 | |
| 596 | const label = |
| 597 | startRev == null ? null : endRev == null || startRev === endRev ? ( |
| 598 | <T replace={{$commit: firstLine(startCommit?.text ?? '')}}>Splitting $commit</T> |
| 599 | ) : ( |
| 600 | <T replace={{$numCommits: endRev - startRev + 1}}>Splitting $numCommits commits</T> |
| 601 | ); |
| 602 | return ( |
| 603 | <div className="split-range-selector-button"> |
| 604 | <Tooltip trigger="click" component={() => <StackRangeSelector />}> |
| 605 | <Button> |
| 606 | <Icon icon="layers" slot="start" /> |
| 607 | <T>Change split range</T> |
| 608 | </Button> |
| 609 | </Tooltip> |
| 610 | {label} |
| 611 | </div> |
| 612 | ); |
| 613 | } |
| 614 | |
| 615 | type DragSelection = { |
| 616 | start: number; |
| 617 | startKey: string; |
| 618 | isDragging: boolean; |
| 619 | } & EnsureAssignedTogether<{ |
| 620 | end: number; |
| 621 | endKey: string; |
| 622 | }>; |
| 623 | |
| 624 | /** Split range should be ordered with start at the bottom of the stack, and end at the top. */ |
| 625 | function orderRevsInDrag(drag: DragSelection): DragSelection { |
| 626 | if (drag.end == null) { |
| 627 | return drag; |
| 628 | } |
| 629 | if (drag.start > drag.end) { |
| 630 | return { |
| 631 | ...drag, |
| 632 | start: drag.end, |
| 633 | startKey: drag.endKey, |
| 634 | end: drag.start, |
| 635 | endKey: drag.startKey, |
| 636 | }; |
| 637 | } |
| 638 | return drag; |
| 639 | } |
| 640 | |
| 641 | function StackRangeSelector() { |
| 642 | const stackEdit = useStackEditState(); |
| 643 | |
| 644 | useThrottledEffect( |
| 645 | () => { |
| 646 | tracker.track('SplitOpenRangeSelector'); |
| 647 | }, |
| 648 | 100, |
| 649 | [], |
| 650 | ); |
| 651 | |
| 652 | const {commitStack} = stackEdit; |
| 653 | let {splitRange} = stackEdit; |
| 654 | const [startRev, endRev] = findStartEndRevs(stackEdit); |
| 655 | const endKey = (endRev != null && commitStack.get(endRev)?.key) || ''; |
| 656 | splitRange = splitRange.set('endKey', endKey); |
| 657 | const mutableRevs = commitStack.mutableRevs().reverse(); |
| 658 | |
| 659 | const startCommitKey = startRev == null ? '' : (commitStack.get(startRev)?.key ?? ''); |
| 660 | const [dragSelection, setDragSelection] = useState<DragSelection>({ |
| 661 | start: startRev ?? 0, |
| 662 | startKey: startCommitKey, |
| 663 | isDragging: false, |
| 664 | }); |
| 665 | |
| 666 | const orderedDrag = orderRevsInDrag(dragSelection); |
| 667 | const selectStart = orderedDrag.start; |
| 668 | const selectEnd = orderedDrag.end ?? selectStart; |
| 669 | |
| 670 | const commits = mutableRevs.map(rev => { |
| 671 | const commit = nullthrows(commitStack.get(rev)); |
| 672 | return ( |
| 673 | <div |
| 674 | onPointerDown={() => { |
| 675 | setDragSelection({start: rev, startKey: commit.key, isDragging: true}); |
| 676 | }} |
| 677 | onPointerEnter={() => { |
| 678 | if (dragSelection?.isDragging === true) { |
| 679 | setDragSelection(old => ({...nullthrows(old), end: rev, endKey: commit.key})); |
| 680 | } |
| 681 | }} |
| 682 | key={rev} |
| 683 | className={ |
| 684 | 'split-range-commit' + |
| 685 | (commit.rev === selectStart ? ' selection-start' : '') + |
| 686 | (commit.rev === selectEnd ? ' selection-end' : '') + |
| 687 | (selectStart != null && |
| 688 | selectEnd != null && |
| 689 | commit.rev > selectStart && |
| 690 | commit.rev < selectEnd |
| 691 | ? ' selection-middle' |
| 692 | : '') |
| 693 | }> |
| 694 | <div className="commit-selection-avatar" /> |
| 695 | <div className="commit-avatar" /> |
| 696 | <div className="commit-title">{firstLine(commit.text)}</div> |
| 697 | </div> |
| 698 | ); |
| 699 | }); |
| 700 | |
| 701 | return ( |
| 702 | <div className="split-range-selector"> |
| 703 | <div className="split-range-selector-info"> |
| 704 | <Icon icon="info" /> |
| 705 | <div> |
| 706 | <b> |
| 707 | <T>Click to select a commit to split.</T> |
| 708 | </b> |
| 709 | <br /> |
| 710 | <T>Click and drag to select a range of commits.</T> |
| 711 | </div> |
| 712 | </div> |
| 713 | <div |
| 714 | className="commit-tree-root commit-group with-vertical-line" |
| 715 | onPointerUp={() => { |
| 716 | // update drag preview |
| 717 | setDragSelection(old => ({...old, isDragging: false})); |
| 718 | |
| 719 | const {startKey, endKey} = orderRevsInDrag(dragSelection); |
| 720 | |
| 721 | // actually change range |
| 722 | let newRange = splitRange; |
| 723 | newRange = newRange.set('startKey', startKey); |
| 724 | newRange = newRange.set('endKey', endKey ?? startKey); |
| 725 | stackEdit.setSplitRange(newRange); |
| 726 | |
| 727 | bumpStackEditMetric('splitChangeRange'); |
| 728 | }}> |
| 729 | <div className="commit-group inner-commit-group">{commits}</div> |
| 730 | <BranchIndicator /> |
| 731 | </div> |
| 732 | </div> |
| 733 | ); |
| 734 | } |
| 735 | |
| 736 | type MaybeEditableCommitTitleProps = { |
| 737 | commitMessage: string; |
| 738 | commitKey?: string; |
| 739 | }; |
| 740 | |
| 741 | function EditableCommitTitle(props: MaybeEditableCommitTitleProps) { |
| 742 | const stackEdit = useStackEditState(); |
| 743 | |
| 744 | const {commitMessage, commitKey} = props; |
| 745 | |
| 746 | const existingTitle = firstLine(commitMessage); |
| 747 | const existingDescription = commitMessage.slice(existingTitle.length + 1); |
| 748 | |
| 749 | // Only allow changing the commit title, not the rest of the commit message. |
| 750 | const handleEdit = (newTitle?: string) => { |
| 751 | if (newTitle != null && commitKey != null) { |
| 752 | const {commitStack} = stackEdit; |
| 753 | const commit = commitStack.findCommitByKey(commitKey); |
| 754 | if (commit != null) { |
| 755 | const newFullText = newTitle + '\n' + existingDescription; |
| 756 | const newStack = commitStack.stack.setIn([commit.rev, 'text'], newFullText); |
| 757 | const newCommitStack = commitStack.set('stack', newStack); |
| 758 | |
| 759 | const previous = stackEdit.undoOperationDescription(); |
| 760 | if (previous != null && previous.name == 'metaedit' && previous.commit.rev === commit.rev) { |
| 761 | // the last operation was also editing this same message, let's reuse the history instead of growing it |
| 762 | stackEdit.replaceTopOperation(newCommitStack, {name: 'metaedit', commit}); |
| 763 | } else { |
| 764 | stackEdit.push(newCommitStack, {name: 'metaedit', commit}); |
| 765 | } |
| 766 | } else { |
| 767 | // If we don't have a real commit for this editor, it's the "fake" blank commit added to the top of the dense stack. |
| 768 | // We need a real commit to associate the newly edited title to, so it can be persisted/is part of the undo stack. |
| 769 | // So we make the fake blank commit into a real blank commit by inserting at the end. |
| 770 | // Note that this will create another fake blank commit AFTER the new real blank commit. |
| 771 | |
| 772 | const [, endRev] = findStartEndRevs(stackEdit); |
| 773 | |
| 774 | const messageTemplate = readAtom(commitMessageTemplate); |
| 775 | const schema = readAtom(commitMessageFieldsSchema); |
| 776 | const fields: CommitMessageFields = {...messageTemplate, Title: newTitle}; |
| 777 | const message = commitMessageFieldsToString(schema, fields); |
| 778 | if (endRev != null) { |
| 779 | const newStack = commitStack.insertEmpty(next(endRev), message); |
| 780 | |
| 781 | const newEnd = newStack.get(next(endRev)); |
| 782 | if (newEnd != null) { |
| 783 | let {splitRange} = stackEdit; |
| 784 | splitRange = splitRange.set('endKey', newEnd.key); |
| 785 | stackEdit.push(newStack, {name: 'insertBlankCommit'}, splitRange); |
| 786 | } |
| 787 | } |
| 788 | } |
| 789 | } |
| 790 | }; |
| 791 | return ( |
| 792 | <TextField |
| 793 | containerXstyle={styles.full} |
| 794 | value={existingTitle} |
| 795 | title={t('Edit commit title')} |
| 796 | style={{width: 'calc(100% - var(--pad))'}} |
| 797 | onInput={e => handleEdit(e.currentTarget?.value)} |
| 798 | /> |
| 799 | ); |
| 800 | } |
| 801 | |
| 802 | const splitMessagePrefix = t('Split of "'); |
| 803 | |
| 804 | function getEmptyCommitTitle(commitMessage: string): string { |
| 805 | let title = ''; |
| 806 | if (!commitMessage.startsWith(splitMessagePrefix)) { |
| 807 | // foo bar -> Split of "foo bar" |
| 808 | title = commitMessage.split('\n', 1)[0]; |
| 809 | title = t('Split of "$title"', {replace: {$title: title}}); |
| 810 | } else { |
| 811 | title = commitMessage.split('\n', 1)[0]; |
| 812 | const sep = t(' #'); |
| 813 | const last = title.split(sep).at(-1) ?? ''; |
| 814 | const number = parseInt(last); |
| 815 | if (number > 0) { |
| 816 | // Split of "foo" #2 -> Split of "foo" #3 |
| 817 | title = title.slice(0, -last.length) + (number + 1).toString(); |
| 818 | } else { |
| 819 | // Split of "foo" -> Split of "foo" #2 |
| 820 | title = title + sep + '2'; |
| 821 | } |
| 822 | } |
| 823 | return title; |
| 824 | } |
| 825 | |
| 826 | type SplitFileProps = { |
| 827 | /** |
| 828 | * File stack to edit. |
| 829 | * |
| 830 | * Note: the editor for rev 1 might want to diff against rev 0 and rev 2, |
| 831 | * and might have buttons to move lines to other revs. So it needs to |
| 832 | * know the entire stack. |
| 833 | */ |
| 834 | stack: FileStackState; |
| 835 | |
| 836 | /** |
| 837 | * Override the "left side" text (diff against). |
| 838 | * |
| 839 | * This is useful to provide the text from the "copyFrom" file. |
| 840 | * Once set, move left buttons will be disabled. |
| 841 | */ |
| 842 | copyFromText?: string; |
| 843 | |
| 844 | /** Function to update the stack. */ |
| 845 | setStack: (stack: FileStackState) => void; |
| 846 | |
| 847 | /** Function to get the "title" of a rev. */ |
| 848 | getTitle?: (rev: FileRev) => string; |
| 849 | |
| 850 | /** |
| 851 | * Skip editing (or showing) given revs. |
| 852 | * This is usually to skip rev 0 (public, empty) if it is absent. |
| 853 | * In the side-by-side mode, rev 0 is shown it it is an existing empty file |
| 854 | * (introduced by a previous public commit). rev 0 is not shown if it is |
| 855 | * absent, aka. rev 1 added the file. |
| 856 | */ |
| 857 | skip?: (rev: FileRev) => boolean; |
| 858 | |
| 859 | /** The rev in the stack to edit. */ |
| 860 | rev: FileRev; |
| 861 | |
| 862 | /** The filepath */ |
| 863 | path: string; |
| 864 | }; |
| 865 | |
| 866 | const useThemeHook = () => useAtomValue(themeState); |
| 867 | |
| 868 | export function SplitFile(props: SplitFileProps) { |
| 869 | const mainContentRef = useRef<HTMLTableElement | null>(null); |
| 870 | const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet); |
| 871 | const [selectedLineIds, setSelectedLineIds] = useState<ImSet<string>>(ImSet); |
| 872 | const {stack, rev, setStack, copyFromText} = props; |
| 873 | |
| 874 | // Selection change is a document event, not a <pre> event. |
| 875 | useEffect(() => { |
| 876 | const handleSelect = () => { |
| 877 | const selection = window.getSelection(); |
| 878 | if ( |
| 879 | selection == null || |
| 880 | mainContentRef.current == null || |
| 881 | !mainContentRef.current.contains(selection.anchorNode) |
| 882 | ) { |
| 883 | setSelectedLineIds(ids => (ids.isEmpty() ? ids : ImSet())); |
| 884 | return; |
| 885 | } |
| 886 | const divs = mainContentRef.current.querySelectorAll<HTMLDivElement>('div[data-sel-id]'); |
| 887 | const selIds: Array<string> = []; |
| 888 | for (const div of divs) { |
| 889 | if ( |
| 890 | (div.lastChild && selection.containsNode(div.lastChild, true)) || |
| 891 | (div.firstChild && selection.containsNode(div.firstChild, true)) |
| 892 | ) { |
| 893 | selIds.push(nullthrows(div.dataset.selId)); |
| 894 | } |
| 895 | } |
| 896 | |
| 897 | setSelectedLineIds(ImSet(selIds)); |
| 898 | }; |
| 899 | document.addEventListener('selectionchange', handleSelect); |
| 900 | return () => { |
| 901 | document.removeEventListener('selectionchange', handleSelect); |
| 902 | }; |
| 903 | }, []); |
| 904 | |
| 905 | // Diff with the left side. |
| 906 | const bText = stack.getRev(rev); |
| 907 | const aText = copyFromText ?? stack.getRev(max(prev(rev), 0)); |
| 908 | // memo to avoid syntax highlighting repeatedly even when the text hasn't changed |
| 909 | const bLines = useMemo(() => splitLines(bText), [bText]); |
| 910 | const aLines = useMemo(() => splitLines(aText), [aText]); |
| 911 | const abBlocks = diffBlocks(aLines, bLines); |
| 912 | |
| 913 | const highlights = useTokenizedContentsOnceVisible( |
| 914 | props.path, |
| 915 | aLines, |
| 916 | bLines, |
| 917 | mainContentRef, |
| 918 | useThemeHook, |
| 919 | ); |
| 920 | const hasCopyFrom = copyFromText != null; |
| 921 | |
| 922 | const {leftGutter, leftButtons, mainContent, rightGutter, rightButtons, lineKind} = |
| 923 | computeLinesForFileStackEditor( |
| 924 | stack, |
| 925 | setStack, |
| 926 | rev, |
| 927 | 'unified-diff', |
| 928 | aLines, |
| 929 | bLines, |
| 930 | highlights?.[0], |
| 931 | highlights?.[1], |
| 932 | abBlocks, |
| 933 | [], |
| 934 | abBlocks, |
| 935 | expandedLines, |
| 936 | setExpandedLines, |
| 937 | selectedLineIds, |
| 938 | [], |
| 939 | false, |
| 940 | false, |
| 941 | hasCopyFrom, |
| 942 | ); |
| 943 | |
| 944 | const rows = mainContent.map((line, i) => ( |
| 945 | <tr key={i} className={lineKind[i]}> |
| 946 | <td className="split-left-button">{leftButtons[i]}</td> |
| 947 | <td className="split-left-lineno">{leftGutter[i]}</td> |
| 948 | <td className="split-line-content">{line}</td> |
| 949 | <td className="split-right-lineno">{rightGutter[i]}</td> |
| 950 | <td className="split-right-button">{rightButtons[i]}</td> |
| 951 | </tr> |
| 952 | )); |
| 953 | |
| 954 | return ( |
| 955 | <div className="split-file"> |
| 956 | <table ref={mainContentRef}> |
| 957 | <colgroup> |
| 958 | <col width={50}>{/* left arrows */}</col> |
| 959 | <col width={50}>{/* before line numbers */}</col> |
| 960 | <col width={'100%'}>{/* diff content */}</col> |
| 961 | <col width={50}>{/* after line numbers */}</col> |
| 962 | <col width={50}>{/* rightarrow */}</col> |
| 963 | </colgroup> |
| 964 | <tbody>{rows}</tbody> |
| 965 | </table> |
| 966 | </div> |
| 967 | ); |
| 968 | } |
| 969 | |
| 970 | export function SplitStackToolbar() { |
| 971 | return <StackRangeSelectorButton />; |
| 972 | } |
| 973 | |