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