|
| 1 | +import cx from 'classnames' |
| 2 | +import invariant from 'invariant' |
| 3 | +import * as React from 'react' |
| 4 | +import * as M from '@material-ui/core' |
| 5 | +import * as Icons from '@material-ui/icons' |
| 6 | +import * as Lab from '@material-ui/lab' |
| 7 | + |
| 8 | +import * as Model from 'model' |
| 9 | + |
| 10 | +import type { RevisionsResult, Revision } from '../useRevisionsPair' |
| 11 | + |
| 12 | +import Preview from './Preview' |
| 13 | +import Revisioned from './Revisioned' |
| 14 | +import useColors from './useColors' |
| 15 | + |
| 16 | +type Color = keyof ReturnType<typeof useColors> |
| 17 | + |
| 18 | +function LogicalKey({ color, children }: React.PropsWithChildren<{ color: Color }>) { |
| 19 | + const colors = useColors() |
| 20 | + return <span className={cx(colors[color], colors.inline)}>{children}</span> |
| 21 | +} |
| 22 | + |
| 23 | +interface EntryProps { |
| 24 | + children: React.ReactNode |
| 25 | + logicalKey: React.ReactNode |
| 26 | + className: string |
| 27 | +} |
| 28 | + |
| 29 | +function Entry({ children, className, logicalKey }: EntryProps) { |
| 30 | + const [expanded, setExpanded] = React.useState(false) |
| 31 | + const toggle = React.useCallback(() => setExpanded((x) => !x), []) |
| 32 | + return ( |
| 33 | + <> |
| 34 | + <M.ListItem button onClick={toggle} className={className}> |
| 35 | + <M.ListItemIcon> |
| 36 | + {expanded ? <Icons.ExpandLess /> : <Icons.ExpandMore />} |
| 37 | + </M.ListItemIcon> |
| 38 | + <M.ListItemText primary={logicalKey} /> |
| 39 | + </M.ListItem> |
| 40 | + |
| 41 | + <M.Collapse in={expanded} unmountOnExit> |
| 42 | + <M.ListItem>{children}</M.ListItem> |
| 43 | + </M.Collapse> |
| 44 | + </> |
| 45 | + ) |
| 46 | +} |
| 47 | + |
| 48 | +type Change = |
| 49 | + | { _tag: 'unmodified'; entry: Model.PackageEntry } |
| 50 | + | { _tag: 'added'; entry: Model.PackageEntry } |
| 51 | + | { _tag: 'removed'; entry: Model.PackageEntry } |
| 52 | + | { _tag: 'modified'; base: Model.PackageEntry; other: Model.PackageEntry } |
| 53 | + |
| 54 | +function isModified(base: Model.PackageEntry, other: Model.PackageEntry): boolean { |
| 55 | + if (base.physicalKey !== other.physicalKey) return true |
| 56 | + if (base.hash.value !== other.hash.value) return true |
| 57 | + if (base.size !== other.size) return true |
| 58 | + if (JSON.stringify(base.meta) !== JSON.stringify(other.meta)) return true |
| 59 | + return false |
| 60 | +} |
| 61 | + |
| 62 | +function getChange( |
| 63 | + baseEntry?: Model.PackageEntry, |
| 64 | + otherEntry?: Model.PackageEntry, |
| 65 | +): Change { |
| 66 | + invariant(baseEntry || otherEntry, 'We iterate over entries, some entry must exist') |
| 67 | + |
| 68 | + if (!baseEntry) return { _tag: 'added', entry: otherEntry! } |
| 69 | + if (!otherEntry) return { _tag: 'removed', entry: baseEntry! } |
| 70 | + |
| 71 | + return isModified(baseEntry, otherEntry) |
| 72 | + ? { _tag: 'modified', base: baseEntry, other: otherEntry } |
| 73 | + : { _tag: 'unmodified', entry: baseEntry } |
| 74 | +} |
| 75 | + |
| 76 | +function getChanges([base, other]: [Revision, Revision], changesOnly: boolean) { |
| 77 | + if (!base.contentsFlatMap && !other.contentsFlatMap) { |
| 78 | + throw new Error(`Package manifests are too large`) |
| 79 | + } |
| 80 | + if (!base.contentsFlatMap) { |
| 81 | + throw new Error(`Package manifest ${base.hash} is too large`) |
| 82 | + } |
| 83 | + if (!other.contentsFlatMap) { |
| 84 | + throw new Error(`Package manifest ${other.hash} is too large`) |
| 85 | + } |
| 86 | + |
| 87 | + return Object.keys({ ...base.contentsFlatMap, ...other.contentsFlatMap }) |
| 88 | + .sort() |
| 89 | + .map((logicalKey) => ({ |
| 90 | + logicalKey, |
| 91 | + change: getChange( |
| 92 | + base.contentsFlatMap?.[logicalKey], |
| 93 | + other.contentsFlatMap?.[logicalKey], |
| 94 | + ), |
| 95 | + })) |
| 96 | + .filter(({ change }) => !changesOnly || change._tag !== 'unmodified') |
| 97 | +} |
| 98 | + |
| 99 | +const usePreviewBoxStyles = M.makeStyles((t) => ({ |
| 100 | + root: { |
| 101 | + background: t.palette.background.paper, |
| 102 | + borderRadius: t.shape.borderRadius, |
| 103 | + color: 'inherit', |
| 104 | + margin: t.spacing(2, 0), |
| 105 | + padding: t.spacing(3), |
| 106 | + position: 'relative', |
| 107 | + }, |
| 108 | + border: { |
| 109 | + borderStyle: 'solid', |
| 110 | + borderWidth: '2px', |
| 111 | + }, |
| 112 | +})) |
| 113 | + |
| 114 | +interface PreviewBoxProps { |
| 115 | + children: React.ReactNode |
| 116 | + className?: string |
| 117 | + hash?: string |
| 118 | + tag: Change['_tag'] |
| 119 | +} |
| 120 | + |
| 121 | +function PreviewBox({ hash, className, children, tag }: PreviewBoxProps) { |
| 122 | + const colors = useColors() |
| 123 | + const classes = usePreviewBoxStyles() |
| 124 | + const cl = cx( |
| 125 | + classes.root, |
| 126 | + tag !== 'unmodified' && colors[tag], |
| 127 | + tag !== 'unmodified' && classes.border, |
| 128 | + className, |
| 129 | + ) |
| 130 | + return hash ? ( |
| 131 | + <Revisioned className={cl} hash={hash}> |
| 132 | + {children} |
| 133 | + </Revisioned> |
| 134 | + ) : ( |
| 135 | + <div className={cl}>{children}</div> |
| 136 | + ) |
| 137 | +} |
| 138 | + |
| 139 | +const useStyles = M.makeStyles((t) => ({ |
| 140 | + row: { |
| 141 | + borderBottom: `1px solid ${t.palette.divider}`, |
| 142 | + '&:last-child': { |
| 143 | + borderBottom: 'none', |
| 144 | + }, |
| 145 | + }, |
| 146 | + head: { |
| 147 | + background: t.palette.background.default, |
| 148 | + ...t.typography.caption, |
| 149 | + }, |
| 150 | + empty: { |
| 151 | + ...t.typography.body2, |
| 152 | + color: t.palette.text.secondary, |
| 153 | + }, |
| 154 | + single: { |
| 155 | + width: '100%', |
| 156 | + }, |
| 157 | + split: { |
| 158 | + display: 'grid', |
| 159 | + gridTemplateColumns: '1fr 1fr', |
| 160 | + gap: t.spacing(2), |
| 161 | + width: '100%', |
| 162 | + }, |
| 163 | +})) |
| 164 | + |
| 165 | +interface EntriesDiffProps { |
| 166 | + revisions: [Revision, Revision] |
| 167 | + changesOnly: boolean |
| 168 | +} |
| 169 | + |
| 170 | +function EntriesDiff({ revisions, changesOnly }: EntriesDiffProps) { |
| 171 | + const classes = useStyles() |
| 172 | + |
| 173 | + const changes = React.useMemo(() => { |
| 174 | + try { |
| 175 | + return getChanges(revisions, changesOnly) |
| 176 | + } catch (e) { |
| 177 | + return e instanceof Error ? e : new Error(`Unexpected error: ${e}`) |
| 178 | + } |
| 179 | + }, [revisions, changesOnly]) |
| 180 | + |
| 181 | + if (changes instanceof Error) { |
| 182 | + return <Lab.Alert severity="error">{changes.message}</Lab.Alert> |
| 183 | + } |
| 184 | + |
| 185 | + if (changes.length === 0) { |
| 186 | + return <p className={classes.empty}>No entries changed</p> |
| 187 | + } |
| 188 | + |
| 189 | + return ( |
| 190 | + <M.List dense> |
| 191 | + {changes.map(({ logicalKey, change }) => ( |
| 192 | + <Entry |
| 193 | + key={logicalKey} |
| 194 | + className={classes.row} |
| 195 | + logicalKey={<LogicalKey color={change._tag}>{logicalKey}</LogicalKey>} |
| 196 | + > |
| 197 | + {change._tag === 'modified' ? ( |
| 198 | + <div className={classes.split}> |
| 199 | + <PreviewBox tag="removed" hash={revisions[0].hash}> |
| 200 | + <Preview physicalKey={change.base.physicalKey} /> |
| 201 | + </PreviewBox> |
| 202 | + <PreviewBox tag="added" hash={revisions[1].hash}> |
| 203 | + <Preview physicalKey={change.other.physicalKey} /> |
| 204 | + </PreviewBox> |
| 205 | + </div> |
| 206 | + ) : ( |
| 207 | + <PreviewBox className={classes.single} tag={change._tag}> |
| 208 | + <Preview physicalKey={change.entry.physicalKey} /> |
| 209 | + </PreviewBox> |
| 210 | + )} |
| 211 | + </Entry> |
| 212 | + ))} |
| 213 | + </M.List> |
| 214 | + ) |
| 215 | +} |
| 216 | + |
| 217 | +interface EntriesDiffWrapperProps { |
| 218 | + revisionsResult: RevisionsResult |
| 219 | + changesOnly: boolean |
| 220 | +} |
| 221 | + |
| 222 | +export default function EntriesDiffHandler({ |
| 223 | + revisionsResult, |
| 224 | + changesOnly, |
| 225 | +}: EntriesDiffWrapperProps) { |
| 226 | + if (revisionsResult._tag === 'loading') { |
| 227 | + return <Lab.Skeleton width="100%" height={200} /> |
| 228 | + } |
| 229 | + |
| 230 | + if (revisionsResult._tag === 'error') { |
| 231 | + return ( |
| 232 | + <Lab.Alert severity="error"> |
| 233 | + <Lab.AlertTitle>Error loading revisions</Lab.AlertTitle> |
| 234 | + {revisionsResult.error.message} |
| 235 | + </Lab.Alert> |
| 236 | + ) |
| 237 | + } |
| 238 | + |
| 239 | + return <EntriesDiff revisions={revisionsResult.revisions} changesOnly={changesOnly} /> |
| 240 | +} |
0 commit comments