30.3 KB973 lines
Blame
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
8import type {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether';
9import type {RepoPath} from 'shared/types/common';
10import type {CommitMessageFields} from '../../CommitInfoView/types';
11import type {CommitRev, CommitStackState, FileMetadata, FileStackIndex} from '../commitStackState';
12import type {FileRev, FileStackState} from '../fileStackState';
13import type {UseStackEditState} from './stackEditState';
14
15import * as stylex from '@stylexjs/stylex';
16import {Set as ImSet, type List, Range} from 'immutable';
17import {Button} from 'isl-components/Button';
18import {Icon} from 'isl-components/Icon';
19import {Subtle} from 'isl-components/Subtle';
20import {TextField} from 'isl-components/TextField';
21import {Tooltip} from 'isl-components/Tooltip';
22import {useAtom, useAtomValue} from 'jotai';
23import {useEffect, useMemo, useRef, useState} from 'react';
24import {useContextMenu} from 'shared/ContextMenu';
25import {readableDiffBlocks as diffBlocks, type LineIdx, splitLines} from 'shared/diff';
26import {useThrottledEffect} from 'shared/hooks';
27import {firstLine, nullthrows} from 'shared/utils';
28import {BranchIndicator} from '../../BranchIndicator';
29import {commitMessageTemplate} from '../../CommitInfoView/CommitInfoState';
30import {
31 commitMessageFieldsSchema,
32 commitMessageFieldsToString,
33} from '../../CommitInfoView/CommitMessageFields';
34import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader';
35import {useTokenizedContentsOnceVisible} from '../../ComparisonView/SplitDiffView/syntaxHighlighting';
36import {Column, Row, ScrollX, ScrollY} from '../../ComponentUtils';
37import {EmptyState} from '../../EmptyState';
38import {useGeneratedFileStatuses} from '../../GeneratedFile';
39import {tracker} from '../../analytics';
40import {t, T} from '../../i18n';
41import {readAtom} from '../../jotaiUtils';
42import {themeState} from '../../theme';
43import {GeneratedStatus} from '../../types';
44import {isAbsent, reorderedRevs} from '../commitStackState';
45import {max, next, prev} from '../revMath';
46import {AISplitButton} from './AISplit';
47import {computeLinesForFileStackEditor} from './FileStackEditorLines';
48import {
49 bumpStackEditMetric,
50 findStartEndRevs,
51 shouldAutoSplitState,
52 SplitRangeRecord,
53 useStackEditState,
54} from './stackEditState';
55
56import './SplitStackEditPanel.css';
57
58const styles = stylex.create({
59 full: {
60 width: '100%',
61 },
62});
63
64export 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
140type SplitColumnProps = {
141 stackEdit: UseStackEditState;
142 commitStack: CommitStackState;
143 subStack: CommitStackState;
144 rev: CommitRev;
145 insertBlankCommit: (rev: CommitRev) => unknown;
146};
147
148function 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
172function 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
211function 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
379type 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
391function 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
528const 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
535function 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
567function 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
575function 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. */
589function 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
615type 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. */
625function 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
641function 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
736type MaybeEditableCommitTitleProps = {
737 commitMessage: string;
738 commitKey?: string;
739};
740
741function 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
802const splitMessagePrefix = t('Split of "');
803
804function 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
826type 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
866const useThemeHook = () => useAtomValue(themeState);
867
868export 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
970export function SplitStackToolbar() {
971 return <StackRangeSelectorButton />;
972}
973