| 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 {Comparison} from 'shared/Comparison'; |
| b69ab31 | | | 9 | import type {ParsedDiff} from 'shared/patch/types'; |
| b69ab31 | | | 10 | import type {Result} from '../types'; |
| b69ab31 | | | 11 | import type {Context} from './SplitDiffView/types'; |
| b69ab31 | | | 12 | |
| b69ab31 | | | 13 | import deepEqual from 'fast-deep-equal'; |
| b69ab31 | | | 14 | import {Button} from 'isl-components/Button'; |
| b69ab31 | | | 15 | import {Dropdown} from 'isl-components/Dropdown'; |
| b69ab31 | | | 16 | import {ErrorBoundary, ErrorNotice} from 'isl-components/ErrorNotice'; |
| b69ab31 | | | 17 | import {Icon} from 'isl-components/Icon'; |
| b69ab31 | | | 18 | import {RadioGroup} from 'isl-components/Radio'; |
| b69ab31 | | | 19 | import {Subtle} from 'isl-components/Subtle'; |
| b69ab31 | | | 20 | import {Tooltip} from 'isl-components/Tooltip'; |
| b69ab31 | | | 21 | import {useAtom, useAtomValue, useSetAtom} from 'jotai'; |
| b69ab31 | | | 22 | import {useEffect, useMemo, useState} from 'react'; |
| b69ab31 | | | 23 | import { |
| b69ab31 | | | 24 | ComparisonType, |
| b69ab31 | | | 25 | comparisonIsAgainstHead, |
| b69ab31 | | | 26 | comparisonStringKey, |
| b69ab31 | | | 27 | labelForComparison, |
| b69ab31 | | | 28 | } from 'shared/Comparison'; |
| b69ab31 | | | 29 | import {group, notEmpty} from 'shared/utils'; |
| b69ab31 | | | 30 | import serverAPI from '../ClientToServerAPI'; |
| b69ab31 | | | 31 | import {EmptyState} from '../EmptyState'; |
| b69ab31 | | | 32 | import {useGeneratedFileStatuses} from '../GeneratedFile'; |
| b69ab31 | | | 33 | import {T, t} from '../i18n'; |
| b69ab31 | | | 34 | import {atomFamilyWeak, atomLoadableWithRefresh, localStorageBackedAtom} from '../jotaiUtils'; |
| b69ab31 | | | 35 | import platform from '../platform'; |
| b69ab31 | | | 36 | import {latestHeadCommit} from '../serverAPIState'; |
| b69ab31 | | | 37 | import {themeState} from '../theme'; |
| b69ab31 | | | 38 | import {GeneratedStatus} from '../types'; |
| b69ab31 | | | 39 | import {SplitDiffView} from './SplitDiffView'; |
| b69ab31 | | | 40 | import {currentComparisonMode} from './atoms'; |
| b69ab31 | | | 41 | import {parsePatchAndFilter, sortFilesByType} from './utils'; |
| b69ab31 | | | 42 | |
| b69ab31 | | | 43 | import './ComparisonView.css'; |
| b69ab31 | | | 44 | |
| b69ab31 | | | 45 | /** |
| b69ab31 | | | 46 | * Transform Result<T> to Result<U> by applying `fn` on result.value. |
| b69ab31 | | | 47 | * If the result is an error, just return it unchanged. |
| b69ab31 | | | 48 | */ |
| b69ab31 | | | 49 | function mapResult<T, U>(result: Result<T>, fn: (t: T) => U): Result<U> { |
| b69ab31 | | | 50 | return result.error == null ? {value: fn(result.value)} : result; |
| b69ab31 | | | 51 | } |
| b69ab31 | | | 52 | |
| b69ab31 | | | 53 | const currentComparisonData = atomFamilyWeak((comparison: Comparison) => |
| b69ab31 | | | 54 | atomLoadableWithRefresh<Result<Array<ParsedDiff>>>(async () => { |
| b69ab31 | | | 55 | serverAPI.postMessage({type: 'requestComparison', comparison}); |
| b69ab31 | | | 56 | const event = await serverAPI.nextMessageMatching('comparison', event => |
| b69ab31 | | | 57 | deepEqual(comparison, event.comparison), |
| b69ab31 | | | 58 | ); |
| b69ab31 | | | 59 | return mapResult(event.data.diff, parsePatchAndFilter); |
| b69ab31 | | | 60 | }), |
| b69ab31 | | | 61 | ); |
| b69ab31 | | | 62 | |
| b69ab31 | | | 63 | type LineRangeKey = string; |
| b69ab31 | | | 64 | export function keyForLineRange(param: {path: string; comparison: Comparison}): LineRangeKey { |
| b69ab31 | | | 65 | return `${param.path}:${comparisonStringKey(param.comparison)}`; |
| b69ab31 | | | 66 | } |
| b69ab31 | | | 67 | |
| b69ab31 | | | 68 | type ComparisonDisplayMode = 'unified' | 'split'; |
| b69ab31 | | | 69 | const comparisonDisplayMode = localStorageBackedAtom<ComparisonDisplayMode | 'responsive'>( |
| b69ab31 | | | 70 | 'isl.comparison-display-mode', |
| b69ab31 | | | 71 | 'responsive', |
| b69ab31 | | | 72 | ); |
| b69ab31 | | | 73 | |
| b69ab31 | | | 74 | export default function ComparisonView({ |
| b69ab31 | | | 75 | comparison, |
| b69ab31 | | | 76 | dismiss, |
| b69ab31 | | | 77 | }: { |
| b69ab31 | | | 78 | comparison: Comparison; |
| b69ab31 | | | 79 | dismiss?: () => void; |
| b69ab31 | | | 80 | }) { |
| b69ab31 | | | 81 | const compared = useAtomValue(currentComparisonData(comparison)); |
| b69ab31 | | | 82 | |
| b69ab31 | | | 83 | const displayMode = useComparisonDisplayMode(); |
| b69ab31 | | | 84 | |
| b69ab31 | | | 85 | const data = compared.state === 'hasData' ? compared.data : null; |
| b69ab31 | | | 86 | |
| b69ab31 | | | 87 | const paths = useMemo( |
| b69ab31 | | | 88 | () => data?.value?.map(file => file.newFileName).filter(notEmpty) ?? [], |
| b69ab31 | | | 89 | [data?.value], |
| b69ab31 | | | 90 | ); |
| b69ab31 | | | 91 | const generatedStatuses = useGeneratedFileStatuses(paths); |
| b69ab31 | | | 92 | const [collapsedFiles, setCollapsedFile] = useCollapsedFilesState({ |
| b69ab31 | | | 93 | isLoading: compared.state === 'loading', |
| b69ab31 | | | 94 | data, |
| b69ab31 | | | 95 | }); |
| b69ab31 | | | 96 | |
| b69ab31 | | | 97 | let content; |
| b69ab31 | | | 98 | if (data == null) { |
| b69ab31 | | | 99 | content = <Icon icon="loading" />; |
| b69ab31 | | | 100 | } else if (compared.state === 'hasError') { |
| b69ab31 | | | 101 | const error = compared.error instanceof Error ? compared.error : new Error(`${compared.error}`); |
| b69ab31 | | | 102 | content = <ErrorNotice error={error} title={t('Unable to load comparison')} />; |
| b69ab31 | | | 103 | } else if (data?.value && data.value.length === 0) { |
| b69ab31 | | | 104 | content = |
| b69ab31 | | | 105 | comparison.type === ComparisonType.SinceLastCodeReviewSubmit ? ( |
| b69ab31 | | | 106 | <EmptyState> |
| b69ab31 | | | 107 | <T>No Content Changes</T> |
| b69ab31 | | | 108 | <br /> |
| b69ab31 | | | 109 | <Subtle> |
| b69ab31 | | | 110 | <T> This commit might have been rebased</T> |
| b69ab31 | | | 111 | </Subtle> |
| b69ab31 | | | 112 | </EmptyState> |
| b69ab31 | | | 113 | ) : ( |
| b69ab31 | | | 114 | <EmptyState> |
| b69ab31 | | | 115 | <T>No Changes</T> |
| b69ab31 | | | 116 | </EmptyState> |
| b69ab31 | | | 117 | ); |
| b69ab31 | | | 118 | } else { |
| b69ab31 | | | 119 | const files = data.value ?? []; |
| b69ab31 | | | 120 | sortFilesByType(files); |
| b69ab31 | | | 121 | const fileGroups = group(files, file => generatedStatuses[file.newFileName ?? '']); |
| b69ab31 | | | 122 | content = ( |
| b69ab31 | | | 123 | <> |
| b69ab31 | | | 124 | {fileGroups[GeneratedStatus.Manual]?.map((parsed, i) => ( |
| b69ab31 | | | 125 | <ComparisonViewFile |
| b69ab31 | | | 126 | diff={parsed} |
| b69ab31 | | | 127 | comparison={comparison} |
| b69ab31 | | | 128 | key={i} |
| b69ab31 | | | 129 | collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false} |
| b69ab31 | | | 130 | setCollapsed={(collapsed: boolean) => |
| b69ab31 | | | 131 | setCollapsedFile(parsed.newFileName ?? '', collapsed) |
| b69ab31 | | | 132 | } |
| b69ab31 | | | 133 | generatedStatus={GeneratedStatus.Manual} |
| b69ab31 | | | 134 | displayMode={displayMode} |
| b69ab31 | | | 135 | /> |
| b69ab31 | | | 136 | ))} |
| b69ab31 | | | 137 | {fileGroups[GeneratedStatus.PartiallyGenerated]?.map((parsed, i) => ( |
| b69ab31 | | | 138 | <ComparisonViewFile |
| b69ab31 | | | 139 | diff={parsed} |
| b69ab31 | | | 140 | comparison={comparison} |
| b69ab31 | | | 141 | key={i} |
| b69ab31 | | | 142 | collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false} |
| b69ab31 | | | 143 | setCollapsed={(collapsed: boolean) => |
| b69ab31 | | | 144 | setCollapsedFile(parsed.newFileName ?? '', collapsed) |
| b69ab31 | | | 145 | } |
| b69ab31 | | | 146 | generatedStatus={GeneratedStatus.PartiallyGenerated} |
| b69ab31 | | | 147 | displayMode={displayMode} |
| b69ab31 | | | 148 | /> |
| b69ab31 | | | 149 | ))} |
| b69ab31 | | | 150 | {fileGroups[GeneratedStatus.Generated]?.map((parsed, i) => ( |
| b69ab31 | | | 151 | <ComparisonViewFile |
| b69ab31 | | | 152 | diff={parsed} |
| b69ab31 | | | 153 | comparison={comparison} |
| b69ab31 | | | 154 | key={i} |
| b69ab31 | | | 155 | collapsed={collapsedFiles.get(parsed.newFileName ?? '') ?? false} |
| b69ab31 | | | 156 | setCollapsed={(collapsed: boolean) => |
| b69ab31 | | | 157 | setCollapsedFile(parsed.newFileName ?? '', collapsed) |
| b69ab31 | | | 158 | } |
| b69ab31 | | | 159 | generatedStatus={GeneratedStatus.Generated} |
| b69ab31 | | | 160 | displayMode={displayMode} |
| b69ab31 | | | 161 | /> |
| b69ab31 | | | 162 | ))} |
| b69ab31 | | | 163 | </> |
| b69ab31 | | | 164 | ); |
| b69ab31 | | | 165 | } |
| b69ab31 | | | 166 | |
| b69ab31 | | | 167 | return ( |
| b69ab31 | | | 168 | <div data-testid="comparison-view" className="comparison-view"> |
| b69ab31 | | | 169 | <ComparisonViewHeader |
| b69ab31 | | | 170 | comparison={comparison} |
| b69ab31 | | | 171 | collapsedFiles={collapsedFiles} |
| b69ab31 | | | 172 | setCollapsedFile={setCollapsedFile} |
| b69ab31 | | | 173 | dismiss={dismiss} |
| b69ab31 | | | 174 | /> |
| b69ab31 | | | 175 | <div className="comparison-view-details">{content}</div> |
| b69ab31 | | | 176 | </div> |
| b69ab31 | | | 177 | ); |
| b69ab31 | | | 178 | } |
| b69ab31 | | | 179 | |
| b69ab31 | | | 180 | const defaultComparisons = [ |
| b69ab31 | | | 181 | ComparisonType.UncommittedChanges as const, |
| b69ab31 | | | 182 | ComparisonType.HeadChanges as const, |
| b69ab31 | | | 183 | ComparisonType.StackChanges as const, |
| b69ab31 | | | 184 | ]; |
| b69ab31 | | | 185 | function ComparisonViewHeader({ |
| b69ab31 | | | 186 | comparison, |
| b69ab31 | | | 187 | collapsedFiles, |
| b69ab31 | | | 188 | setCollapsedFile, |
| b69ab31 | | | 189 | dismiss, |
| b69ab31 | | | 190 | }: { |
| b69ab31 | | | 191 | comparison: Comparison; |
| b69ab31 | | | 192 | collapsedFiles: Map<string, boolean>; |
| b69ab31 | | | 193 | setCollapsedFile: (path: string, collapsed: boolean) => unknown; |
| b69ab31 | | | 194 | dismiss?: () => void; |
| b69ab31 | | | 195 | }) { |
| b69ab31 | | | 196 | const setComparisonMode = useSetAtom(currentComparisonMode); |
| b69ab31 | | | 197 | const [compared, reloadComparison] = useAtom(currentComparisonData(comparison)); |
| b69ab31 | | | 198 | |
| b69ab31 | | | 199 | const data = compared.state === 'hasData' ? compared.data : null; |
| b69ab31 | | | 200 | |
| b69ab31 | | | 201 | const allFilesExpanded = |
| b69ab31 | | | 202 | data?.value?.every( |
| b69ab31 | | | 203 | file => file.newFileName && collapsedFiles.get(file.newFileName) === false, |
| b69ab31 | | | 204 | ) === true; |
| b69ab31 | | | 205 | const noFilesExpanded = |
| b69ab31 | | | 206 | data?.value?.every( |
| b69ab31 | | | 207 | file => file.newFileName && collapsedFiles.get(file.newFileName) === true, |
| b69ab31 | | | 208 | ) === true; |
| b69ab31 | | | 209 | const isLoading = compared.state === 'loading'; |
| b69ab31 | | | 210 | |
| b69ab31 | | | 211 | return ( |
| b69ab31 | | | 212 | <> |
| b69ab31 | | | 213 | <div className="comparison-view-header"> |
| b69ab31 | | | 214 | <span className="comparison-view-header-group"> |
| b69ab31 | | | 215 | <Dropdown |
| b69ab31 | | | 216 | data-testid="comparison-view-picker" |
| b69ab31 | | | 217 | value={comparison.type} |
| b69ab31 | | | 218 | onChange={event => { |
| b69ab31 | | | 219 | const newComparison = { |
| b69ab31 | | | 220 | type: (event as React.FormEvent<HTMLSelectElement>).currentTarget |
| b69ab31 | | | 221 | .value as (typeof defaultComparisons)[0], |
| b69ab31 | | | 222 | }; |
| b69ab31 | | | 223 | setComparisonMode(previous => ({ |
| b69ab31 | | | 224 | ...previous, |
| b69ab31 | | | 225 | comparison: newComparison, |
| b69ab31 | | | 226 | })); |
| b69ab31 | | | 227 | // When viewed in a dedicated viewer, change the title as the comparison changes |
| b69ab31 | | | 228 | if (window.islAppMode != null && window.islAppMode.mode != 'isl') { |
| b69ab31 | | | 229 | serverAPI.postMessage({ |
| b69ab31 | | | 230 | type: 'platform/changeTitle', |
| b69ab31 | | | 231 | title: labelForComparison(newComparison), |
| b69ab31 | | | 232 | }); |
| b69ab31 | | | 233 | } |
| b69ab31 | | | 234 | }} |
| b69ab31 | | | 235 | options={[ |
| b69ab31 | | | 236 | ...defaultComparisons.map(comparison => ({ |
| b69ab31 | | | 237 | value: comparison, |
| b69ab31 | | | 238 | name: labelForComparison({type: comparison}), |
| b69ab31 | | | 239 | })), |
| b69ab31 | | | 240 | |
| b69ab31 | | | 241 | !defaultComparisons.includes(comparison.type as (typeof defaultComparisons)[0]) |
| b69ab31 | | | 242 | ? {value: comparison.type, name: labelForComparison(comparison)} |
| b69ab31 | | | 243 | : undefined, |
| b69ab31 | | | 244 | ].filter(notEmpty)} |
| b69ab31 | | | 245 | /> |
| b69ab31 | | | 246 | <Tooltip |
| b69ab31 | | | 247 | delayMs={1000} |
| b69ab31 | | | 248 | title={t('Reload this comparison. Comparisons do not refresh automatically.')}> |
| b69ab31 | | | 249 | <Button onClick={reloadComparison}> |
| b69ab31 | | | 250 | <Icon icon="refresh" data-testid="comparison-refresh-button" /> |
| b69ab31 | | | 251 | </Button> |
| b69ab31 | | | 252 | </Tooltip> |
| b69ab31 | | | 253 | <Button |
| b69ab31 | | | 254 | onClick={() => { |
| b69ab31 | | | 255 | for (const file of data?.value ?? []) { |
| b69ab31 | | | 256 | if (file.newFileName) { |
| b69ab31 | | | 257 | setCollapsedFile(file.newFileName, false); |
| b69ab31 | | | 258 | } |
| b69ab31 | | | 259 | } |
| b69ab31 | | | 260 | }} |
| b69ab31 | | | 261 | disabled={isLoading || allFilesExpanded} |
| b69ab31 | | | 262 | icon> |
| b69ab31 | | | 263 | <Icon icon="unfold" slot="start" /> |
| b69ab31 | | | 264 | <T>Expand all files</T> |
| b69ab31 | | | 265 | </Button> |
| b69ab31 | | | 266 | <Button |
| b69ab31 | | | 267 | onClick={() => { |
| b69ab31 | | | 268 | for (const file of data?.value ?? []) { |
| b69ab31 | | | 269 | if (file.newFileName) { |
| b69ab31 | | | 270 | setCollapsedFile(file.newFileName, true); |
| b69ab31 | | | 271 | } |
| b69ab31 | | | 272 | } |
| b69ab31 | | | 273 | }} |
| b69ab31 | | | 274 | icon |
| b69ab31 | | | 275 | disabled={isLoading || noFilesExpanded}> |
| b69ab31 | | | 276 | <Icon icon="fold" slot="start" /> |
| b69ab31 | | | 277 | <T>Collapse all files</T> |
| b69ab31 | | | 278 | </Button> |
| b69ab31 | | | 279 | <Tooltip trigger="click" component={() => <ComparisonSettingsDropdown />}> |
| b69ab31 | | | 280 | <Button icon> |
| b69ab31 | | | 281 | <Icon icon="ellipsis" /> |
| b69ab31 | | | 282 | </Button> |
| b69ab31 | | | 283 | </Tooltip> |
| b69ab31 | | | 284 | {isLoading ? <Icon icon="loading" data-testid="comparison-loading" /> : null} |
| b69ab31 | | | 285 | </span> |
| b69ab31 | | | 286 | {dismiss == null ? null : ( |
| b69ab31 | | | 287 | <Button data-testid="close-comparison-view-button" icon onClick={dismiss}> |
| b69ab31 | | | 288 | <Icon icon="x" /> |
| b69ab31 | | | 289 | </Button> |
| b69ab31 | | | 290 | )} |
| b69ab31 | | | 291 | </div> |
| b69ab31 | | | 292 | </> |
| b69ab31 | | | 293 | ); |
| b69ab31 | | | 294 | } |
| b69ab31 | | | 295 | |
| b69ab31 | | | 296 | function ComparisonSettingsDropdown() { |
| b69ab31 | | | 297 | const [mode, setMode] = useAtom(comparisonDisplayMode); |
| b69ab31 | | | 298 | return ( |
| b69ab31 | | | 299 | <div className="dropdown-field"> |
| b69ab31 | | | 300 | <RadioGroup |
| b69ab31 | | | 301 | title={t('Comparison Display Mode')} |
| b69ab31 | | | 302 | choices={[ |
| b69ab31 | | | 303 | {value: 'responsive', title: <T>Responsive</T>}, |
| b69ab31 | | | 304 | {value: 'split', title: <T>Split</T>}, |
| b69ab31 | | | 305 | {value: 'unified', title: <T>Unified</T>}, |
| b69ab31 | | | 306 | ]} |
| b69ab31 | | | 307 | current={mode} |
| b69ab31 | | | 308 | onChange={setMode} |
| b69ab31 | | | 309 | /> |
| b69ab31 | | | 310 | </div> |
| b69ab31 | | | 311 | ); |
| b69ab31 | | | 312 | } |
| b69ab31 | | | 313 | |
| b69ab31 | | | 314 | /** |
| b69ab31 | | | 315 | * Derive from the parsed diff state which files should be expanded or collapsed by default. |
| b69ab31 | | | 316 | * This state is the source of truth of which files are expanded/collapsed. |
| b69ab31 | | | 317 | * This is a hook instead of a recoil selector since it depends on the comparison |
| b69ab31 | | | 318 | * which is a prop. |
| b69ab31 | | | 319 | */ |
| b69ab31 | | | 320 | function useCollapsedFilesState(data: { |
| b69ab31 | | | 321 | isLoading: boolean; |
| b69ab31 | | | 322 | data: Result<Array<ParsedDiff>> | null; |
| b69ab31 | | | 323 | }): [Map<string, boolean>, (path: string, collapsed: boolean) => void] { |
| b69ab31 | | | 324 | const [collapsedFiles, setCollapsedFiles] = useState(new Map()); |
| b69ab31 | | | 325 | |
| b69ab31 | | | 326 | useEffect(() => { |
| b69ab31 | | | 327 | if (data.isLoading || data.data?.value == null) { |
| b69ab31 | | | 328 | return; |
| b69ab31 | | | 329 | } |
| b69ab31 | | | 330 | |
| b69ab31 | | | 331 | const newCollapsedFiles = new Map(collapsedFiles); |
| b69ab31 | | | 332 | |
| b69ab31 | | | 333 | // Allocate a number of changed lines we're willing to show expanded by default, |
| b69ab31 | | | 334 | // add files until we just cross that threshold. |
| b69ab31 | | | 335 | // This means a single very large file will start expanded already. |
| b69ab31 | | | 336 | const TOTAL_DEFAULT_EXPANDED_SIZE = 4000; |
| b69ab31 | | | 337 | let accumulatedSize = 0; |
| b69ab31 | | | 338 | let indexToStartCollapsing = Infinity; |
| b69ab31 | | | 339 | for (const [i, diff] of data.data.value.entries()) { |
| b69ab31 | | | 340 | const sizeThisFile = diff.hunks.reduce((last, hunk) => last + hunk.lines.length, 0); |
| b69ab31 | | | 341 | accumulatedSize += sizeThisFile; |
| b69ab31 | | | 342 | if (accumulatedSize > TOTAL_DEFAULT_EXPANDED_SIZE) { |
| b69ab31 | | | 343 | indexToStartCollapsing = i; |
| b69ab31 | | | 344 | break; |
| b69ab31 | | | 345 | } |
| b69ab31 | | | 346 | } |
| b69ab31 | | | 347 | |
| b69ab31 | | | 348 | let anyChanged = false; |
| b69ab31 | | | 349 | for (const [i, diff] of data.data.value.entries()) { |
| b69ab31 | | | 350 | if (!newCollapsedFiles.has(diff.newFileName)) { |
| b69ab31 | | | 351 | newCollapsedFiles.set(diff.newFileName, i > 0 && i >= indexToStartCollapsing); |
| b69ab31 | | | 352 | anyChanged = true; |
| b69ab31 | | | 353 | } |
| b69ab31 | | | 354 | // Leave existing files alone in case the user changed their expanded state. |
| b69ab31 | | | 355 | } |
| b69ab31 | | | 356 | if (anyChanged) { |
| b69ab31 | | | 357 | setCollapsedFiles(newCollapsedFiles); |
| b69ab31 | | | 358 | // We don't bother removing files that no longer appear in the list of files. |
| b69ab31 | | | 359 | // That's not a big deal, this state is local to this instance of the comparison view anyway. |
| b69ab31 | | | 360 | } |
| b69ab31 | | | 361 | }, [data, collapsedFiles]); |
| b69ab31 | | | 362 | |
| b69ab31 | | | 363 | const setCollapsed = (path: string, collapsed: boolean) => { |
| b69ab31 | | | 364 | setCollapsedFiles(prev => { |
| b69ab31 | | | 365 | const map = new Map(prev); |
| b69ab31 | | | 366 | map.set(path, collapsed); |
| b69ab31 | | | 367 | return map; |
| b69ab31 | | | 368 | }); |
| b69ab31 | | | 369 | }; |
| b69ab31 | | | 370 | |
| b69ab31 | | | 371 | return [collapsedFiles, setCollapsed]; |
| b69ab31 | | | 372 | } |
| b69ab31 | | | 373 | |
| b69ab31 | | | 374 | function splitOrUnifiedBasedOnWidth() { |
| b69ab31 | | | 375 | return window.innerWidth > 600 ? 'split' : 'unified'; |
| b69ab31 | | | 376 | } |
| b69ab31 | | | 377 | function useComparisonDisplayMode(): ComparisonDisplayMode { |
| b69ab31 | | | 378 | const underlyingMode = useAtomValue(comparisonDisplayMode); |
| b69ab31 | | | 379 | const [mode, setMode] = useState( |
| b69ab31 | | | 380 | underlyingMode === 'responsive' ? splitOrUnifiedBasedOnWidth() : underlyingMode, |
| b69ab31 | | | 381 | ); |
| b69ab31 | | | 382 | useEffect(() => { |
| b69ab31 | | | 383 | if (underlyingMode !== 'responsive') { |
| b69ab31 | | | 384 | setMode(underlyingMode); |
| b69ab31 | | | 385 | return; |
| b69ab31 | | | 386 | } |
| b69ab31 | | | 387 | const update = () => { |
| b69ab31 | | | 388 | setMode(splitOrUnifiedBasedOnWidth()); |
| b69ab31 | | | 389 | }; |
| b69ab31 | | | 390 | update(); |
| b69ab31 | | | 391 | window.addEventListener('resize', update); |
| b69ab31 | | | 392 | return () => window.removeEventListener('resize', update); |
| b69ab31 | | | 393 | }, [underlyingMode, setMode]); |
| b69ab31 | | | 394 | |
| b69ab31 | | | 395 | return mode; |
| b69ab31 | | | 396 | } |
| b69ab31 | | | 397 | |
| b69ab31 | | | 398 | function ComparisonViewFile({ |
| b69ab31 | | | 399 | diff, |
| b69ab31 | | | 400 | comparison, |
| b69ab31 | | | 401 | collapsed, |
| b69ab31 | | | 402 | setCollapsed, |
| b69ab31 | | | 403 | generatedStatus, |
| b69ab31 | | | 404 | displayMode, |
| b69ab31 | | | 405 | }: { |
| b69ab31 | | | 406 | diff: ParsedDiff; |
| b69ab31 | | | 407 | comparison: Comparison; |
| b69ab31 | | | 408 | collapsed: boolean; |
| b69ab31 | | | 409 | setCollapsed: (isCollapsed: boolean) => void; |
| b69ab31 | | | 410 | generatedStatus: GeneratedStatus; |
| b69ab31 | | | 411 | displayMode: ComparisonDisplayMode; |
| b69ab31 | | | 412 | }) { |
| b69ab31 | | | 413 | const path = diff.newFileName ?? diff.oldFileName ?? ''; |
| b69ab31 | | | 414 | const context: Context = { |
| b69ab31 | | | 415 | id: {path, comparison}, |
| b69ab31 | | | 416 | copy: platform.clipboardCopy, |
| b69ab31 | | | 417 | openFile: () => platform.openFile(path), |
| b69ab31 | | | 418 | // only offer clickable line numbers for comparisons against head, otherwise line numbers will be inaccurate |
| b69ab31 | | | 419 | openFileToLine: comparisonIsAgainstHead(comparison) |
| b69ab31 | | | 420 | ? (line: number) => platform.openFile(path, {line}) |
| b69ab31 | | | 421 | : undefined, |
| b69ab31 | | | 422 | |
| b69ab31 | | | 423 | async fetchAdditionalLines(id, start, numLines) { |
| b69ab31 | | | 424 | serverAPI.postMessage({ |
| b69ab31 | | | 425 | type: 'requestComparisonContextLines', |
| b69ab31 | | | 426 | numLines, |
| b69ab31 | | | 427 | start, |
| b69ab31 | | | 428 | id, |
| b69ab31 | | | 429 | }); |
| b69ab31 | | | 430 | |
| b69ab31 | | | 431 | const result = await serverAPI.nextMessageMatching( |
| b69ab31 | | | 432 | 'comparisonContextLines', |
| b69ab31 | | | 433 | msg => msg.path === id.path, |
| b69ab31 | | | 434 | ); |
| b69ab31 | | | 435 | |
| b69ab31 | | | 436 | return result.lines; |
| b69ab31 | | | 437 | }, |
| b69ab31 | | | 438 | // We must ensure the lineRange gets invalidated when the underlying file's context lines |
| b69ab31 | | | 439 | // have changed. |
| b69ab31 | | | 440 | // This depends on the comparison: |
| b69ab31 | | | 441 | // for Committed: the commit hash is included in the Comparison, thus the cached data will always be accurate. |
| b69ab31 | | | 442 | // for Uncommitted, Head, and Stack: |
| b69ab31 | | | 443 | // by referencing the latest head commit's hash, we ensure this selector reloads when the head commit changes. |
| b69ab31 | | | 444 | // These comparisons are all against the working copy (not exactly head), |
| b69ab31 | | | 445 | // but there's no change that could be made that would affect the context lines without |
| b69ab31 | | | 446 | // also changing the head commit's hash. |
| b69ab31 | | | 447 | // Note: we use latestHeadCommit WITHOUT previews, so we don't accidentally cache the file content |
| b69ab31 | | | 448 | // AGAIN on the same data while waiting for some new operation to finish. |
| b69ab31 | | | 449 | // eslint-disable-next-line react-hooks/rules-of-hooks |
| b69ab31 | | | 450 | useComparisonInvalidationKeyHook: () => useAtomValue(latestHeadCommit)?.hash ?? '', |
| b69ab31 | | | 451 | useThemeHook: () => useAtomValue(themeState), |
| b69ab31 | | | 452 | t, |
| b69ab31 | | | 453 | collapsed, |
| b69ab31 | | | 454 | setCollapsed, |
| b69ab31 | | | 455 | display: displayMode, |
| b69ab31 | | | 456 | }; |
| b69ab31 | | | 457 | return ( |
| b69ab31 | | | 458 | <div className="comparison-view-file" key={path}> |
| b69ab31 | | | 459 | <ErrorBoundary> |
| b69ab31 | | | 460 | <SplitDiffView ctx={context} patch={diff} path={path} generatedStatus={generatedStatus} /> |
| b69ab31 | | | 461 | </ErrorBoundary> |
| b69ab31 | | | 462 | </div> |
| b69ab31 | | | 463 | ); |
| b69ab31 | | | 464 | } |