addons/isl/src/stackEdit/ui/SplitStackEditPanel.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether';
b69ab319import type {RepoPath} from 'shared/types/common';
b69ab3110import type {CommitMessageFields} from '../../CommitInfoView/types';
b69ab3111import type {CommitRev, CommitStackState, FileMetadata, FileStackIndex} from '../commitStackState';
b69ab3112import type {FileRev, FileStackState} from '../fileStackState';
b69ab3113import type {UseStackEditState} from './stackEditState';
b69ab3114
b69ab3115import * as stylex from '@stylexjs/stylex';
b69ab3116import {Set as ImSet, type List, Range} from 'immutable';
b69ab3117import {Button} from 'isl-components/Button';
b69ab3118import {Icon} from 'isl-components/Icon';
b69ab3119import {Subtle} from 'isl-components/Subtle';
b69ab3120import {TextField} from 'isl-components/TextField';
b69ab3121import {Tooltip} from 'isl-components/Tooltip';
b69ab3122import {useAtom, useAtomValue} from 'jotai';
b69ab3123import {useEffect, useMemo, useRef, useState} from 'react';
b69ab3124import {useContextMenu} from 'shared/ContextMenu';
b69ab3125import {readableDiffBlocks as diffBlocks, type LineIdx, splitLines} from 'shared/diff';
b69ab3126import {useThrottledEffect} from 'shared/hooks';
b69ab3127import {firstLine, nullthrows} from 'shared/utils';
b69ab3128import {BranchIndicator} from '../../BranchIndicator';
b69ab3129import {commitMessageTemplate} from '../../CommitInfoView/CommitInfoState';
b69ab3130import {
b69ab3131 commitMessageFieldsSchema,
b69ab3132 commitMessageFieldsToString,
b69ab3133} from '../../CommitInfoView/CommitMessageFields';
b69ab3134import {FileHeader, IconType} from '../../ComparisonView/SplitDiffView/SplitDiffFileHeader';
b69ab3135import {useTokenizedContentsOnceVisible} from '../../ComparisonView/SplitDiffView/syntaxHighlighting';
b69ab3136import {Column, Row, ScrollX, ScrollY} from '../../ComponentUtils';
b69ab3137import {EmptyState} from '../../EmptyState';
b69ab3138import {useGeneratedFileStatuses} from '../../GeneratedFile';
b69ab3139import {tracker} from '../../analytics';
b69ab3140import {t, T} from '../../i18n';
b69ab3141import {readAtom} from '../../jotaiUtils';
b69ab3142import {themeState} from '../../theme';
b69ab3143import {GeneratedStatus} from '../../types';
b69ab3144import {isAbsent, reorderedRevs} from '../commitStackState';
b69ab3145import {max, next, prev} from '../revMath';
b69ab3146import {AISplitButton} from './AISplit';
b69ab3147import {computeLinesForFileStackEditor} from './FileStackEditorLines';
b69ab3148import {
b69ab3149 bumpStackEditMetric,
b69ab3150 findStartEndRevs,
b69ab3151 shouldAutoSplitState,
b69ab3152 SplitRangeRecord,
b69ab3153 useStackEditState,
b69ab3154} from './stackEditState';
b69ab3155
b69ab3156import './SplitStackEditPanel.css';
b69ab3157
b69ab3158const styles = stylex.create({
b69ab3159 full: {
b69ab3160 width: '100%',
b69ab3161 },
b69ab3162});
b69ab3163
b69ab3164export function SplitStackEditPanel() {
b69ab3165 const stackEdit = useStackEditState();
b69ab3166
b69ab3167 const {commitStack} = stackEdit;
b69ab3168
b69ab3169 const messageTemplate = useAtomValue(commitMessageTemplate);
b69ab3170 const schema = useAtomValue(commitMessageFieldsSchema);
b69ab3171
b69ab3172 // Find the commits being split.
b69ab3173 const [startRev, endRev] = findStartEndRevs(stackEdit);
b69ab3174
b69ab3175 // Nothing to split? Show a dropdown.
b69ab3176 if (startRev == null || endRev == null || startRev > endRev) {
b69ab3177 return (
b69ab3178 <div>
b69ab3179 <EmptyState small>
b69ab3180 <T>Select a commit to split its changes.</T>
b69ab3181 <br />
b69ab3182 <Subtle>
b69ab3183 <T>Or, select a range of commits to move contents among them.</T>
b69ab3184 </Subtle>
b69ab3185 </EmptyState>
b69ab3186 </div>
b69ab3187 );
b69ab3188 }
b69ab3189
b69ab3190 // Prepare a "dense" subStack with an extra empty commit to move right.
b69ab3191 const emptyTitle = getEmptyCommitTitle(commitStack.get(endRev)?.text ?? '');
b69ab3192 const fields: CommitMessageFields = {...messageTemplate, Title: emptyTitle};
b69ab3193 const message = commitMessageFieldsToString(schema, fields);
b69ab3194 const subStack = commitStack
b69ab3195 .insertEmpty(next(endRev), message, endRev)
b69ab3196 .denseSubStack(Range(startRev, endRev + 2).toList() as List<CommitRev>);
b69ab3197
b69ab3198 const insertBlankCommit = (rev: CommitRev) => {
b69ab3199 const fields: CommitMessageFields = {...messageTemplate, Title: t('New Commit')};
b69ab31100 const message = commitMessageFieldsToString(schema, fields);
b69ab31101
b69ab31102 const newStack = stackEdit.commitStack.insertEmpty((startRev + rev) as CommitRev, message);
b69ab31103
b69ab31104 bumpStackEditMetric('splitInsertBlank');
b69ab31105
b69ab31106 let {splitRange} = stackEdit;
b69ab31107 if (rev === 0) {
b69ab31108 const newStart = newStack.get(startRev);
b69ab31109 if (newStart != null) {
b69ab31110 splitRange = splitRange.set('startKey', newStart.key);
b69ab31111 }
b69ab31112 }
b69ab31113
b69ab31114 stackEdit.push(newStack, {name: 'insertBlankCommit'}, splitRange);
b69ab31115 };
b69ab31116
b69ab31117 // One commit per column.
b69ab31118 const columns: JSX.Element[] = subStack
b69ab31119 .revs()
b69ab31120 .map(rev => (
b69ab31121 <SplitColumn
b69ab31122 stackEdit={stackEdit}
b69ab31123 commitStack={commitStack}
b69ab31124 key={rev}
b69ab31125 rev={rev}
b69ab31126 subStack={subStack}
b69ab31127 insertBlankCommit={insertBlankCommit}
b69ab31128 />
b69ab31129 ));
b69ab31130
b69ab31131 return (
b69ab31132 <div className="interactive-split">
b69ab31133 <ScrollX maxSize="calc((100vw / var(--zoom)) - 30px)">
b69ab31134 <Row style={{padding: '0 var(--pad)', alignItems: 'flex-start'}}>{columns}</Row>
b69ab31135 </ScrollX>
b69ab31136 </div>
b69ab31137 );
b69ab31138}
b69ab31139
b69ab31140type SplitColumnProps = {
b69ab31141 stackEdit: UseStackEditState;
b69ab31142 commitStack: CommitStackState;
b69ab31143 subStack: CommitStackState;
b69ab31144 rev: CommitRev;
b69ab31145 insertBlankCommit: (rev: CommitRev) => unknown;
b69ab31146};
b69ab31147
b69ab31148function InsertBlankCommitButton({
b69ab31149 beforeRev,
b69ab31150 onClick,
b69ab31151}: {
b69ab31152 beforeRev: CommitRev | undefined;
b69ab31153 onClick: () => unknown;
b69ab31154}) {
b69ab31155 return (
b69ab31156 <div className="split-insert-blank-commit-container" role="button" onClick={onClick}>
b69ab31157 <Tooltip
b69ab31158 placement="top"
b69ab31159 title={
b69ab31160 beforeRev == 0
b69ab31161 ? t('Insert a new blank commit before the next commit')
b69ab31162 : t('Insert a new blank commit between these commits')
b69ab31163 }>
b69ab31164 <div className="split-insert-blank-commit">
b69ab31165 <Icon icon="add" />
b69ab31166 </div>
b69ab31167 </Tooltip>
b69ab31168 </div>
b69ab31169 );
b69ab31170}
b69ab31171
b69ab31172function SwapCommitsButton({
b69ab31173 stackEdit,
b69ab31174 beforeRev,
b69ab31175}: {
b69ab31176 stackEdit: UseStackEditState;
b69ab31177 beforeRev: CommitRev | undefined;
b69ab31178}) {
b69ab31179 if (beforeRev == null || beforeRev === 0) {
b69ab31180 return null;
b69ab31181 }
b69ab31182 const state = stackEdit.commitStack;
b69ab31183 const beforeRevCommit = state.get(beforeRev);
b69ab31184 if (beforeRevCommit == null) {
b69ab31185 return null;
b69ab31186 }
b69ab31187 const newOrder = reorderedRevs(state, beforeRev);
b69ab31188 const canSwap = state.canReorder(newOrder);
b69ab31189 if (!canSwap) {
b69ab31190 return null;
b69ab31191 }
b69ab31192 return (
b69ab31193 <div
b69ab31194 className="split-insert-blank-commit-container"
b69ab31195 role="button"
b69ab31196 onClick={() => {
b69ab31197 stackEdit.push(state.reorder(newOrder), {
b69ab31198 name: 'swap',
b69ab31199 });
b69ab31200 bumpStackEditMetric('swapLeftRight');
b69ab31201 }}>
b69ab31202 <Tooltip placement="top" title={t('Swap the order of two commits.')}>
b69ab31203 <div className="split-insert-blank-commit">
b69ab31204 <Icon icon="arrow-swap" />
b69ab31205 </div>
b69ab31206 </Tooltip>
b69ab31207 </div>
b69ab31208 );
b69ab31209}
b69ab31210
b69ab31211function SplitColumn(props: SplitColumnProps) {
b69ab31212 const {stackEdit, commitStack, subStack, rev, insertBlankCommit} = props;
b69ab31213
b69ab31214 const [collapsedFiles, setCollapsedFiles] = useState(new Set());
b69ab31215
b69ab31216 const toggleCollapsed = (path: RepoPath) => {
b69ab31217 const updated = new Set(collapsedFiles);
b69ab31218 updated.has(path) ? updated.delete(path) : updated.add(path);
b69ab31219 setCollapsedFiles(updated);
b69ab31220 };
b69ab31221
b69ab31222 const commit = subStack.get(rev);
b69ab31223 const commitMessage = commit?.text ?? '';
b69ab31224
b69ab31225 // File stacks contain text (content-editable) files.
b69ab31226 // Note: subStack might contain files that are not editable
b69ab31227 // (ex. currently binary, but previously absent). Filter them out.
b69ab31228 const editablePaths = subStack.getPaths(rev, {text: true});
b69ab31229 const editablePathsSet = new Set(editablePaths);
b69ab31230 const generatedStatuses = useGeneratedFileStatuses(editablePaths);
b69ab31231 const sortedFileStacks = subStack.fileStacks
b69ab31232 .flatMap((fileStack, fileIdx): Array<[RepoPath, FileStackState, FileStackIndex]> => {
b69ab31233 const path = subStack.getFileStackPath(fileIdx, 0 as FileRev) ?? '';
b69ab31234 return editablePathsSet.has(path) ? [[path, fileStack, fileIdx]] : [];
b69ab31235 })
b69ab31236 .sort((a, b) => {
b69ab31237 const [pathA] = a;
b69ab31238 const [pathB] = b;
b69ab31239
b69ab31240 const statusA = generatedStatuses[pathA] ?? GeneratedStatus.Manual;
b69ab31241 const statusB = generatedStatuses[pathB] ?? GeneratedStatus.Manual;
b69ab31242
b69ab31243 return statusA === statusB ? pathA.localeCompare(pathB) : statusA - statusB;
b69ab31244 });
b69ab31245
b69ab31246 // There might be non-text (ex. binary, or too large) files.
b69ab31247 const nonEditablePaths = subStack.getPaths(rev, {text: false}).sort();
b69ab31248
b69ab31249 const editables = sortedFileStacks.flatMap(([path, fileStack, fileIdx]) => {
b69ab31250 // subStack is a "dense" stack. fileRev is commitRev + 1.
b69ab31251 const fileRev = (rev + 1) as FileRev;
b69ab31252 const isModified =
b69ab31253 (fileRev > 0 && fileStack.getRev(prev(fileRev)) !== fileStack.getRev(fileRev)) ||
b69ab31254 subStack.changedFileMetadata(rev, path) != null;
b69ab31255 const editor = (
b69ab31256 <SplitEditorWithTitle
b69ab31257 key={path}
b69ab31258 subStack={subStack}
b69ab31259 rev={rev}
b69ab31260 path={path}
b69ab31261 fileStack={fileStack}
b69ab31262 fileIdx={fileIdx}
b69ab31263 fileRev={fileRev}
b69ab31264 collapsed={collapsedFiles.has(path)}
b69ab31265 toggleCollapsed={() => toggleCollapsed(path)}
b69ab31266 generatedStatus={generatedStatuses[path]}
b69ab31267 />
b69ab31268 );
b69ab31269 const result = isModified ? [editor] : [];
b69ab31270 return result;
b69ab31271 });
b69ab31272
b69ab31273 const nonEditables = nonEditablePaths.flatMap(path => {
b69ab31274 const file = subStack.getFile(rev, path);
b69ab31275 const prevFile = subStack.getFile(prev(rev), path);
b69ab31276 const isModified = !file.equals(prevFile);
b69ab31277 if (!isModified) {
b69ab31278 return [];
b69ab31279 }
b69ab31280 const editor = (
b69ab31281 <SplitEditorWithTitle
b69ab31282 key={path}
b69ab31283 subStack={subStack}
b69ab31284 rev={rev}
b69ab31285 path={path}
b69ab31286 collapsed={collapsedFiles.has(path)}
b69ab31287 toggleCollapsed={() => toggleCollapsed(path)}
b69ab31288 />
b69ab31289 );
b69ab31290 return [editor];
b69ab31291 });
b69ab31292
b69ab31293 const editors = editables.concat(nonEditables);
b69ab31294
b69ab31295 const body = editors.isEmpty() ? (
b69ab31296 <EmptyState small>
b69ab31297 <Column>
b69ab31298 <T>This commit is empty</T>
b69ab31299 <Subtle>
b69ab31300 <T>Use the left/right arrows to move files and lines of code and create new commits.</T>
b69ab31301 </Subtle>
b69ab31302 </Column>
b69ab31303 </EmptyState>
b69ab31304 ) : (
b69ab31305 <ScrollY maxSize="calc((100vh / var(--zoom)) - var(--split-vertical-overhead))" hideBar={true}>
b69ab31306 {editors}
b69ab31307 </ScrollY>
b69ab31308 );
b69ab31309
b69ab31310 const showExtraCommitActionsContextMenu = useContextMenu(() => {
b69ab31311 const options = [];
b69ab31312 const allFiles = new Set(sortedFileStacks.map(([path]) => path));
b69ab31313 if (collapsedFiles.size < allFiles.size && allFiles.size > 0) {
b69ab31314 options.push({
b69ab31315 label: t('Collapse all files'),
b69ab31316 onClick() {
b69ab31317 setCollapsedFiles(allFiles);
b69ab31318 },
b69ab31319 });
b69ab31320 }
b69ab31321 if (collapsedFiles.size > 0) {
b69ab31322 options.push({
b69ab31323 label: t('Expand all files'),
b69ab31324 onClick() {
b69ab31325 setCollapsedFiles(new Set());
b69ab31326 },
b69ab31327 });
b69ab31328 }
b69ab31329 return options;
b69ab31330 });
b69ab31331
b69ab31332 const [shouldAutoSplit, setShouldAutoSplit] = useAtom(shouldAutoSplitState);
b69ab31333 const aiSplitButtonRef = useRef<HTMLButtonElement | null>(null);
b69ab31334
b69ab31335 useEffect(() => {
b69ab31336 const autoTriggerAISplit = () => {
b69ab31337 if (aiSplitButtonRef.current != null) {
b69ab31338 aiSplitButtonRef.current.click();
b69ab31339 }
b69ab31340 };
b69ab31341
b69ab31342 if (shouldAutoSplit) {
b69ab31343 setShouldAutoSplit(false);
b69ab31344 autoTriggerAISplit();
b69ab31345 }
b69ab31346 }, [setShouldAutoSplit, shouldAutoSplit]);
b69ab31347
b69ab31348 return (
b69ab31349 <>
b69ab31350 {editors.isEmpty() ? null : (
b69ab31351 <Column>
b69ab31352 <InsertBlankCommitButton beforeRev={rev} onClick={() => insertBlankCommit(rev)} />
b69ab31353 <SwapCommitsButton stackEdit={stackEdit} beforeRev={rev} />
b69ab31354 </Column>
b69ab31355 )}
b69ab31356 <div className="split-commit-column">
b69ab31357 <div className="split-commit-header">
b69ab31358 <span className="split-commit-header-stack-number">
b69ab31359 {rev + 1} / {subStack.size}
b69ab31360 </span>
b69ab31361 <EditableCommitTitle commitMessage={commitMessage} commitKey={commit?.key} />
b69ab31362 <AISplitButton
b69ab31363 stackEdit={stackEdit}
b69ab31364 commitStack={commitStack}
b69ab31365 subStack={subStack}
b69ab31366 rev={rev}
b69ab31367 ref={aiSplitButtonRef}
b69ab31368 />
b69ab31369 <Button icon onClick={e => showExtraCommitActionsContextMenu(e)}>
b69ab31370 <Icon icon="ellipsis" />
b69ab31371 </Button>
b69ab31372 </div>
b69ab31373 {body}
b69ab31374 </div>
b69ab31375 </>
b69ab31376 );
b69ab31377}
b69ab31378
b69ab31379type SplitEditorWithTitleProps = {
b69ab31380 subStack: CommitStackState;
b69ab31381 rev: CommitRev;
b69ab31382 path: RepoPath;
b69ab31383 fileStack?: FileStackState;
b69ab31384 fileIdx?: number;
b69ab31385 fileRev?: FileRev;
b69ab31386 collapsed: boolean;
b69ab31387 toggleCollapsed: () => unknown;
b69ab31388 generatedStatus?: GeneratedStatus;
b69ab31389};
b69ab31390
b69ab31391function SplitEditorWithTitle(props: SplitEditorWithTitleProps) {
b69ab31392 const stackEdit = useStackEditState();
b69ab31393
b69ab31394 const {commitStack} = stackEdit;
b69ab31395 const {
b69ab31396 subStack,
b69ab31397 path,
b69ab31398 fileStack,
b69ab31399 fileIdx,
b69ab31400 fileRev,
b69ab31401 collapsed,
b69ab31402 toggleCollapsed,
b69ab31403 rev,
b69ab31404 generatedStatus,
b69ab31405 } = props;
b69ab31406 const file = subStack.getFile(rev, path);
b69ab31407 const [showGeneratedFileAnyway, setShowGeneratedFileAnyway] = useState(false);
b69ab31408
b69ab31409 const setSubStack = (newSubStack: CommitStackState) => {
b69ab31410 const [startRev, endRev] = findStartEndRevs(stackEdit);
b69ab31411 if (startRev != null && endRev != null) {
b69ab31412 const newCommitStack = commitStack.applySubStack(startRev, next(endRev), newSubStack);
b69ab31413 // Find the new split range.
b69ab31414 const endOffset = newCommitStack.size - commitStack.size;
b69ab31415 const startKey = newCommitStack.get(startRev)?.key ?? '';
b69ab31416 const endKey = newCommitStack.get(next(endRev, endOffset))?.key ?? '';
b69ab31417 const splitRange = SplitRangeRecord({startKey, endKey});
b69ab31418 // Update the main stack state.
b69ab31419 stackEdit.push(newCommitStack, {name: 'split', path}, splitRange);
b69ab31420 }
b69ab31421 };
b69ab31422
b69ab31423 const setStack = (newFileStack: FileStackState) => {
b69ab31424 if (fileIdx == null || fileRev == null) {
b69ab31425 return;
b69ab31426 }
b69ab31427 const newSubStack = subStack.setFileStack(fileIdx, newFileStack);
b69ab31428 setSubStack(newSubStack);
b69ab31429 };
b69ab31430
b69ab31431 const moveEntireFile = (dir: 'left' | 'right') => {
b69ab31432 // Suppose the file has 5 versions, and current version is 'v3':
b69ab31433 // v1--v2--v3--v4--v5
b69ab31434 // Move left:
b69ab31435 // v1--v3--v3--v4--v5 (replace v2 with v3)
b69ab31436 // If v3 has 'copyFrom', drop 'copyFrom' on the second 'v3'.
b69ab31437 // If v2 had 'copyFrom', preserve it on the first 'v3'.
b69ab31438 // Move right:
b69ab31439 // v1--v2--v2--v4--v5 (replace v3 with v2)
b69ab31440 // If v3 has 'copyFrom', update 'copyFrom' on 'v4'.
b69ab31441 // v4 should not have 'copyFrom'.
b69ab31442 const [fromRev, toRev] = dir === 'left' ? [rev, prev(rev)] : [prev(rev), rev];
b69ab31443 const fromFile = subStack.getFile(fromRev, path);
b69ab31444 let newStack = subStack.setFile(toRev, path, oldFile => {
b69ab31445 if (dir === 'left' && oldFile.copyFrom != null) {
b69ab31446 return fromFile.set('copyFrom', oldFile.copyFrom);
b69ab31447 }
b69ab31448 return fromFile;
b69ab31449 });
b69ab31450 if (file.copyFrom != null) {
b69ab31451 if (dir === 'right') {
b69ab31452 newStack = newStack.setFile(next(rev), path, f => f.set('copyFrom', file.copyFrom));
b69ab31453 } else {
b69ab31454 newStack = newStack.setFile(rev, path, f => f.remove('copyFrom'));
b69ab31455 }
b69ab31456 }
b69ab31457 bumpStackEditMetric('splitMoveFile');
b69ab31458 setSubStack(newStack);
b69ab31459 };
b69ab31460
b69ab31461 const changedMeta = subStack.changedFileMetadata(rev, path, false);
b69ab31462 let iconType = IconType.Modified;
b69ab31463 if (changedMeta != null) {
b69ab31464 const [oldMeta, newMeta] = changedMeta;
b69ab31465 if (isAbsent(oldMeta) && !isAbsent(newMeta)) {
b69ab31466 iconType = IconType.Added;
b69ab31467 } else if (!isAbsent(oldMeta) && isAbsent(newMeta)) {
b69ab31468 iconType = IconType.Removed;
b69ab31469 }
b69ab31470 }
b69ab31471 const canMoveLeft =
b69ab31472 rev > 0 && (file.copyFrom == null || isAbsent(subStack.getFile(prev(rev), path)));
b69ab31473 let copyFromText = undefined;
b69ab31474 if (file.copyFrom != null) {
b69ab31475 const copyFromFile = subStack.getFile(prev(rev), file.copyFrom);
b69ab31476 try {
b69ab31477 // This will throw if copyFromFile is non-text (binary, or too large).
b69ab31478 copyFromText = subStack.getUtf8Data(copyFromFile);
b69ab31479 } catch {}
b69ab31480 }
b69ab31481
b69ab31482 return (
b69ab31483 <div className="split-commit-file">
b69ab31484 <FileHeader
b69ab31485 path={path}
b69ab31486 copyFrom={file.copyFrom}
b69ab31487 iconType={iconType}
b69ab31488 open={!collapsed}
b69ab31489 onChangeOpen={toggleCollapsed}
b69ab31490 fileActions={
b69ab31491 <div className="split-commit-file-arrows">
b69ab31492 {canMoveLeft ? (
b69ab31493 <Button icon onClick={() => moveEntireFile('left')}>
b69ab31494 ⬅
b69ab31495 </Button>
b69ab31496 ) : null}
b69ab31497 <Button icon onClick={() => moveEntireFile('right')}>
b69ab31498 ⮕
b69ab31499 </Button>
b69ab31500 </div>
b69ab31501 }
b69ab31502 />
b69ab31503 {!collapsed && (
b69ab31504 <>
b69ab31505 <ModeChangeHints changedMeta={changedMeta} />
b69ab31506 {fileRev != null && fileStack != null ? (
b69ab31507 !showGeneratedFileAnyway && generatedStatus !== GeneratedStatus.Manual ? (
b69ab31508 <Generated onShowAnyway={setShowGeneratedFileAnyway} />
b69ab31509 ) : (
b69ab31510 <SplitFile
b69ab31511 key={fileIdx}
b69ab31512 rev={fileRev}
b69ab31513 stack={fileStack}
b69ab31514 setStack={setStack}
b69ab31515 path={path}
b69ab31516 copyFromText={copyFromText}
b69ab31517 />
b69ab31518 )
b69ab31519 ) : (
b69ab31520 <NonEditable />
b69ab31521 )}
b69ab31522 </>
b69ab31523 )}
b69ab31524 </div>
b69ab31525 );
b69ab31526}
b69ab31527
b69ab31528const FLAG_TO_MESSAGE = new Map<string, string>([
b69ab31529 ['', t('regular')],
b69ab31530 ['l', t('symlink')],
b69ab31531 ['x', t('executable')],
b69ab31532 ['m', t('Git submodule')],
b69ab31533]);
b69ab31534
b69ab31535function ModeChangeHints(props: {changedMeta?: [FileMetadata, FileMetadata]}) {
b69ab31536 const {changedMeta} = props;
b69ab31537 if (changedMeta == null) {
b69ab31538 return null;
b69ab31539 }
b69ab31540
b69ab31541 const [oldMeta, newMeta] = changedMeta;
b69ab31542 const oldFlag = oldMeta.flags ?? '';
b69ab31543 const newFlag = newMeta.flags ?? '';
b69ab31544 let message = null;
b69ab31545
b69ab31546 if (!isAbsent(newMeta)) {
b69ab31547 const newDesc = FLAG_TO_MESSAGE.get(newFlag);
b69ab31548 // Show hint for newly added non-regular files.
b69ab31549 if (newFlag !== '' && isAbsent(oldMeta)) {
b69ab31550 if (newDesc != null) {
b69ab31551 message = t('File type: $new', {replace: {$new: newDesc}});
b69ab31552 }
b69ab31553 } else {
b69ab31554 // Show hint when the flag (mode) has changed.
b69ab31555 if (newFlag !== oldFlag) {
b69ab31556 const oldDesc = FLAG_TO_MESSAGE.get(oldFlag);
b69ab31557 if (oldDesc != null && newDesc != null && oldDesc !== newDesc) {
b69ab31558 message = t('File type change: $old → $new', {replace: {$old: oldDesc, $new: newDesc}});
b69ab31559 }
b69ab31560 }
b69ab31561 }
b69ab31562 }
b69ab31563
b69ab31564 return message == null ? null : <div className="split-header-hint">{message}</div>;
b69ab31565}
b69ab31566
b69ab31567function NonEditable() {
b69ab31568 return (
b69ab31569 <div className="split-header-hint">
b69ab31570 <T>Binary or large file content is not editable.</T>
b69ab31571 </div>
b69ab31572 );
b69ab31573}
b69ab31574
b69ab31575function Generated({onShowAnyway}: {onShowAnyway: (show: boolean) => void}) {
b69ab31576 return (
b69ab31577 <div className="split-header-hint">
b69ab31578 <Column>
b69ab31579 <T>This file is generated</T>
b69ab31580 <Button icon onClick={() => onShowAnyway(true)}>
b69ab31581 <T>Show anyway</T>
b69ab31582 </Button>
b69ab31583 </Column>
b69ab31584 </div>
b69ab31585 );
b69ab31586}
b69ab31587
b69ab31588/** Open dialog to select a commit range to split. */
b69ab31589function StackRangeSelectorButton() {
b69ab31590 const stackEdit = useStackEditState();
b69ab31591
b69ab31592 const [startRev, endRev] = findStartEndRevs(stackEdit);
b69ab31593 const {commitStack} = stackEdit;
b69ab31594 const startCommit = startRev == null ? null : commitStack.get(startRev);
b69ab31595
b69ab31596 const label =
b69ab31597 startRev == null ? null : endRev == null || startRev === endRev ? (
b69ab31598 <T replace={{$commit: firstLine(startCommit?.text ?? '')}}>Splitting $commit</T>
b69ab31599 ) : (
b69ab31600 <T replace={{$numCommits: endRev - startRev + 1}}>Splitting $numCommits commits</T>
b69ab31601 );
b69ab31602 return (
b69ab31603 <div className="split-range-selector-button">
b69ab31604 <Tooltip trigger="click" component={() => <StackRangeSelector />}>
b69ab31605 <Button>
b69ab31606 <Icon icon="layers" slot="start" />
b69ab31607 <T>Change split range</T>
b69ab31608 </Button>
b69ab31609 </Tooltip>
b69ab31610 {label}
b69ab31611 </div>
b69ab31612 );
b69ab31613}
b69ab31614
b69ab31615type DragSelection = {
b69ab31616 start: number;
b69ab31617 startKey: string;
b69ab31618 isDragging: boolean;
b69ab31619} & EnsureAssignedTogether<{
b69ab31620 end: number;
b69ab31621 endKey: string;
b69ab31622}>;
b69ab31623
b69ab31624/** Split range should be ordered with start at the bottom of the stack, and end at the top. */
b69ab31625function orderRevsInDrag(drag: DragSelection): DragSelection {
b69ab31626 if (drag.end == null) {
b69ab31627 return drag;
b69ab31628 }
b69ab31629 if (drag.start > drag.end) {
b69ab31630 return {
b69ab31631 ...drag,
b69ab31632 start: drag.end,
b69ab31633 startKey: drag.endKey,
b69ab31634 end: drag.start,
b69ab31635 endKey: drag.startKey,
b69ab31636 };
b69ab31637 }
b69ab31638 return drag;
b69ab31639}
b69ab31640
b69ab31641function StackRangeSelector() {
b69ab31642 const stackEdit = useStackEditState();
b69ab31643
b69ab31644 useThrottledEffect(
b69ab31645 () => {
b69ab31646 tracker.track('SplitOpenRangeSelector');
b69ab31647 },
b69ab31648 100,
b69ab31649 [],
b69ab31650 );
b69ab31651
b69ab31652 const {commitStack} = stackEdit;
b69ab31653 let {splitRange} = stackEdit;
b69ab31654 const [startRev, endRev] = findStartEndRevs(stackEdit);
b69ab31655 const endKey = (endRev != null && commitStack.get(endRev)?.key) || '';
b69ab31656 splitRange = splitRange.set('endKey', endKey);
b69ab31657 const mutableRevs = commitStack.mutableRevs().reverse();
b69ab31658
b69ab31659 const startCommitKey = startRev == null ? '' : (commitStack.get(startRev)?.key ?? '');
b69ab31660 const [dragSelection, setDragSelection] = useState<DragSelection>({
b69ab31661 start: startRev ?? 0,
b69ab31662 startKey: startCommitKey,
b69ab31663 isDragging: false,
b69ab31664 });
b69ab31665
b69ab31666 const orderedDrag = orderRevsInDrag(dragSelection);
b69ab31667 const selectStart = orderedDrag.start;
b69ab31668 const selectEnd = orderedDrag.end ?? selectStart;
b69ab31669
b69ab31670 const commits = mutableRevs.map(rev => {
b69ab31671 const commit = nullthrows(commitStack.get(rev));
b69ab31672 return (
b69ab31673 <div
b69ab31674 onPointerDown={() => {
b69ab31675 setDragSelection({start: rev, startKey: commit.key, isDragging: true});
b69ab31676 }}
b69ab31677 onPointerEnter={() => {
b69ab31678 if (dragSelection?.isDragging === true) {
b69ab31679 setDragSelection(old => ({...nullthrows(old), end: rev, endKey: commit.key}));
b69ab31680 }
b69ab31681 }}
b69ab31682 key={rev}
b69ab31683 className={
b69ab31684 'split-range-commit' +
b69ab31685 (commit.rev === selectStart ? ' selection-start' : '') +
b69ab31686 (commit.rev === selectEnd ? ' selection-end' : '') +
b69ab31687 (selectStart != null &&
b69ab31688 selectEnd != null &&
b69ab31689 commit.rev > selectStart &&
b69ab31690 commit.rev < selectEnd
b69ab31691 ? ' selection-middle'
b69ab31692 : '')
b69ab31693 }>
b69ab31694 <div className="commit-selection-avatar" />
b69ab31695 <div className="commit-avatar" />
b69ab31696 <div className="commit-title">{firstLine(commit.text)}</div>
b69ab31697 </div>
b69ab31698 );
b69ab31699 });
b69ab31700
b69ab31701 return (
b69ab31702 <div className="split-range-selector">
b69ab31703 <div className="split-range-selector-info">
b69ab31704 <Icon icon="info" />
b69ab31705 <div>
b69ab31706 <b>
b69ab31707 <T>Click to select a commit to split.</T>
b69ab31708 </b>
b69ab31709 <br />
b69ab31710 <T>Click and drag to select a range of commits.</T>
b69ab31711 </div>
b69ab31712 </div>
b69ab31713 <div
b69ab31714 className="commit-tree-root commit-group with-vertical-line"
b69ab31715 onPointerUp={() => {
b69ab31716 // update drag preview
b69ab31717 setDragSelection(old => ({...old, isDragging: false}));
b69ab31718
b69ab31719 const {startKey, endKey} = orderRevsInDrag(dragSelection);
b69ab31720
b69ab31721 // actually change range
b69ab31722 let newRange = splitRange;
b69ab31723 newRange = newRange.set('startKey', startKey);
b69ab31724 newRange = newRange.set('endKey', endKey ?? startKey);
b69ab31725 stackEdit.setSplitRange(newRange);
b69ab31726
b69ab31727 bumpStackEditMetric('splitChangeRange');
b69ab31728 }}>
b69ab31729 <div className="commit-group inner-commit-group">{commits}</div>
b69ab31730 <BranchIndicator />
b69ab31731 </div>
b69ab31732 </div>
b69ab31733 );
b69ab31734}
b69ab31735
b69ab31736type MaybeEditableCommitTitleProps = {
b69ab31737 commitMessage: string;
b69ab31738 commitKey?: string;
b69ab31739};
b69ab31740
b69ab31741function EditableCommitTitle(props: MaybeEditableCommitTitleProps) {
b69ab31742 const stackEdit = useStackEditState();
b69ab31743
b69ab31744 const {commitMessage, commitKey} = props;
b69ab31745
b69ab31746 const existingTitle = firstLine(commitMessage);
b69ab31747 const existingDescription = commitMessage.slice(existingTitle.length + 1);
b69ab31748
b69ab31749 // Only allow changing the commit title, not the rest of the commit message.
b69ab31750 const handleEdit = (newTitle?: string) => {
b69ab31751 if (newTitle != null && commitKey != null) {
b69ab31752 const {commitStack} = stackEdit;
b69ab31753 const commit = commitStack.findCommitByKey(commitKey);
b69ab31754 if (commit != null) {
b69ab31755 const newFullText = newTitle + '\n' + existingDescription;
b69ab31756 const newStack = commitStack.stack.setIn([commit.rev, 'text'], newFullText);
b69ab31757 const newCommitStack = commitStack.set('stack', newStack);
b69ab31758
b69ab31759 const previous = stackEdit.undoOperationDescription();
b69ab31760 if (previous != null && previous.name == 'metaedit' && previous.commit.rev === commit.rev) {
b69ab31761 // the last operation was also editing this same message, let's reuse the history instead of growing it
b69ab31762 stackEdit.replaceTopOperation(newCommitStack, {name: 'metaedit', commit});
b69ab31763 } else {
b69ab31764 stackEdit.push(newCommitStack, {name: 'metaedit', commit});
b69ab31765 }
b69ab31766 } else {
b69ab31767 // 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.
b69ab31768 // We need a real commit to associate the newly edited title to, so it can be persisted/is part of the undo stack.
b69ab31769 // So we make the fake blank commit into a real blank commit by inserting at the end.
b69ab31770 // Note that this will create another fake blank commit AFTER the new real blank commit.
b69ab31771
b69ab31772 const [, endRev] = findStartEndRevs(stackEdit);
b69ab31773
b69ab31774 const messageTemplate = readAtom(commitMessageTemplate);
b69ab31775 const schema = readAtom(commitMessageFieldsSchema);
b69ab31776 const fields: CommitMessageFields = {...messageTemplate, Title: newTitle};
b69ab31777 const message = commitMessageFieldsToString(schema, fields);
b69ab31778 if (endRev != null) {
b69ab31779 const newStack = commitStack.insertEmpty(next(endRev), message);
b69ab31780
b69ab31781 const newEnd = newStack.get(next(endRev));
b69ab31782 if (newEnd != null) {
b69ab31783 let {splitRange} = stackEdit;
b69ab31784 splitRange = splitRange.set('endKey', newEnd.key);
b69ab31785 stackEdit.push(newStack, {name: 'insertBlankCommit'}, splitRange);
b69ab31786 }
b69ab31787 }
b69ab31788 }
b69ab31789 }
b69ab31790 };
b69ab31791 return (
b69ab31792 <TextField
b69ab31793 containerXstyle={styles.full}
b69ab31794 value={existingTitle}
b69ab31795 title={t('Edit commit title')}
b69ab31796 style={{width: 'calc(100% - var(--pad))'}}
b69ab31797 onInput={e => handleEdit(e.currentTarget?.value)}
b69ab31798 />
b69ab31799 );
b69ab31800}
b69ab31801
b69ab31802const splitMessagePrefix = t('Split of "');
b69ab31803
b69ab31804function getEmptyCommitTitle(commitMessage: string): string {
b69ab31805 let title = '';
b69ab31806 if (!commitMessage.startsWith(splitMessagePrefix)) {
b69ab31807 // foo bar -> Split of "foo bar"
b69ab31808 title = commitMessage.split('\n', 1)[0];
b69ab31809 title = t('Split of "$title"', {replace: {$title: title}});
b69ab31810 } else {
b69ab31811 title = commitMessage.split('\n', 1)[0];
b69ab31812 const sep = t(' #');
b69ab31813 const last = title.split(sep).at(-1) ?? '';
b69ab31814 const number = parseInt(last);
b69ab31815 if (number > 0) {
b69ab31816 // Split of "foo" #2 -> Split of "foo" #3
b69ab31817 title = title.slice(0, -last.length) + (number + 1).toString();
b69ab31818 } else {
b69ab31819 // Split of "foo" -> Split of "foo" #2
b69ab31820 title = title + sep + '2';
b69ab31821 }
b69ab31822 }
b69ab31823 return title;
b69ab31824}
b69ab31825
b69ab31826type SplitFileProps = {
b69ab31827 /**
b69ab31828 * File stack to edit.
b69ab31829 *
b69ab31830 * Note: the editor for rev 1 might want to diff against rev 0 and rev 2,
b69ab31831 * and might have buttons to move lines to other revs. So it needs to
b69ab31832 * know the entire stack.
b69ab31833 */
b69ab31834 stack: FileStackState;
b69ab31835
b69ab31836 /**
b69ab31837 * Override the "left side" text (diff against).
b69ab31838 *
b69ab31839 * This is useful to provide the text from the "copyFrom" file.
b69ab31840 * Once set, move left buttons will be disabled.
b69ab31841 */
b69ab31842 copyFromText?: string;
b69ab31843
b69ab31844 /** Function to update the stack. */
b69ab31845 setStack: (stack: FileStackState) => void;
b69ab31846
b69ab31847 /** Function to get the "title" of a rev. */
b69ab31848 getTitle?: (rev: FileRev) => string;
b69ab31849
b69ab31850 /**
b69ab31851 * Skip editing (or showing) given revs.
b69ab31852 * This is usually to skip rev 0 (public, empty) if it is absent.
b69ab31853 * In the side-by-side mode, rev 0 is shown it it is an existing empty file
b69ab31854 * (introduced by a previous public commit). rev 0 is not shown if it is
b69ab31855 * absent, aka. rev 1 added the file.
b69ab31856 */
b69ab31857 skip?: (rev: FileRev) => boolean;
b69ab31858
b69ab31859 /** The rev in the stack to edit. */
b69ab31860 rev: FileRev;
b69ab31861
b69ab31862 /** The filepath */
b69ab31863 path: string;
b69ab31864};
b69ab31865
b69ab31866const useThemeHook = () => useAtomValue(themeState);
b69ab31867
b69ab31868export function SplitFile(props: SplitFileProps) {
b69ab31869 const mainContentRef = useRef<HTMLTableElement | null>(null);
b69ab31870 const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet);
b69ab31871 const [selectedLineIds, setSelectedLineIds] = useState<ImSet<string>>(ImSet);
b69ab31872 const {stack, rev, setStack, copyFromText} = props;
b69ab31873
b69ab31874 // Selection change is a document event, not a <pre> event.
b69ab31875 useEffect(() => {
b69ab31876 const handleSelect = () => {
b69ab31877 const selection = window.getSelection();
b69ab31878 if (
b69ab31879 selection == null ||
b69ab31880 mainContentRef.current == null ||
b69ab31881 !mainContentRef.current.contains(selection.anchorNode)
b69ab31882 ) {
b69ab31883 setSelectedLineIds(ids => (ids.isEmpty() ? ids : ImSet()));
b69ab31884 return;
b69ab31885 }
b69ab31886 const divs = mainContentRef.current.querySelectorAll<HTMLDivElement>('div[data-sel-id]');
b69ab31887 const selIds: Array<string> = [];
b69ab31888 for (const div of divs) {
b69ab31889 if (
b69ab31890 (div.lastChild && selection.containsNode(div.lastChild, true)) ||
b69ab31891 (div.firstChild && selection.containsNode(div.firstChild, true))
b69ab31892 ) {
b69ab31893 selIds.push(nullthrows(div.dataset.selId));
b69ab31894 }
b69ab31895 }
b69ab31896
b69ab31897 setSelectedLineIds(ImSet(selIds));
b69ab31898 };
b69ab31899 document.addEventListener('selectionchange', handleSelect);
b69ab31900 return () => {
b69ab31901 document.removeEventListener('selectionchange', handleSelect);
b69ab31902 };
b69ab31903 }, []);
b69ab31904
b69ab31905 // Diff with the left side.
b69ab31906 const bText = stack.getRev(rev);
b69ab31907 const aText = copyFromText ?? stack.getRev(max(prev(rev), 0));
b69ab31908 // memo to avoid syntax highlighting repeatedly even when the text hasn't changed
b69ab31909 const bLines = useMemo(() => splitLines(bText), [bText]);
b69ab31910 const aLines = useMemo(() => splitLines(aText), [aText]);
b69ab31911 const abBlocks = diffBlocks(aLines, bLines);
b69ab31912
b69ab31913 const highlights = useTokenizedContentsOnceVisible(
b69ab31914 props.path,
b69ab31915 aLines,
b69ab31916 bLines,
b69ab31917 mainContentRef,
b69ab31918 useThemeHook,
b69ab31919 );
b69ab31920 const hasCopyFrom = copyFromText != null;
b69ab31921
b69ab31922 const {leftGutter, leftButtons, mainContent, rightGutter, rightButtons, lineKind} =
b69ab31923 computeLinesForFileStackEditor(
b69ab31924 stack,
b69ab31925 setStack,
b69ab31926 rev,
b69ab31927 'unified-diff',
b69ab31928 aLines,
b69ab31929 bLines,
b69ab31930 highlights?.[0],
b69ab31931 highlights?.[1],
b69ab31932 abBlocks,
b69ab31933 [],
b69ab31934 abBlocks,
b69ab31935 expandedLines,
b69ab31936 setExpandedLines,
b69ab31937 selectedLineIds,
b69ab31938 [],
b69ab31939 false,
b69ab31940 false,
b69ab31941 hasCopyFrom,
b69ab31942 );
b69ab31943
b69ab31944 const rows = mainContent.map((line, i) => (
b69ab31945 <tr key={i} className={lineKind[i]}>
b69ab31946 <td className="split-left-button">{leftButtons[i]}</td>
b69ab31947 <td className="split-left-lineno">{leftGutter[i]}</td>
b69ab31948 <td className="split-line-content">{line}</td>
b69ab31949 <td className="split-right-lineno">{rightGutter[i]}</td>
b69ab31950 <td className="split-right-button">{rightButtons[i]}</td>
b69ab31951 </tr>
b69ab31952 ));
b69ab31953
b69ab31954 return (
b69ab31955 <div className="split-file">
b69ab31956 <table ref={mainContentRef}>
b69ab31957 <colgroup>
b69ab31958 <col width={50}>{/* left arrows */}</col>
b69ab31959 <col width={50}>{/* before line numbers */}</col>
b69ab31960 <col width={'100%'}>{/* diff content */}</col>
b69ab31961 <col width={50}>{/* after line numbers */}</col>
b69ab31962 <col width={50}>{/* rightarrow */}</col>
b69ab31963 </colgroup>
b69ab31964 <tbody>{rows}</tbody>
b69ab31965 </table>
b69ab31966 </div>
b69ab31967 );
b69ab31968}
b69ab31969
b69ab31970export function SplitStackToolbar() {
b69ab31971 return <StackRangeSelectorButton />;
b69ab31972}