Skip to content

Commit 7b50f00

Browse files
authored
Package revisions comparison (#4582)
1 parent 94addfc commit 7b50f00

35 files changed

Lines changed: 2485 additions & 75 deletions

catalog/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ where verb is one of
1818

1919
## Changes
2020

21+
- [Added] Package revisions comparison ([#4582](https://github.com/quiltdata/quilt/pull/4582))
2122
- [Changed] Migrate unit-tests from `react-test-renderer` to `@testing-library/react` ([#4540](https://github.com/quiltdata/quilt/pull/4540))
2223
- [Changed] Request required fields for `PackageRevision` to proper cache ([#4583](https://github.com/quiltdata/quilt/pull/4583))
2324
- [Changed] Qurator: make tool messages less prominent ([#4572](https://github.com/quiltdata/quilt/pull/4572))

catalog/app/components/Hash.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import cx from 'classnames'
2+
import * as React from 'react'
3+
import * as M from '@material-ui/core'
4+
5+
const useStyles = M.makeStyles((t) => ({
6+
root: {
7+
...t.typography.monospace,
8+
},
9+
}))
10+
11+
interface Props {
12+
className?: string
13+
children: string
14+
}
15+
16+
export function Full({ className, children }: Props) {
17+
const classes = useStyles()
18+
return <span className={cx(classes.root, className)}>{children}</span>
19+
}
20+
21+
export function Trimmed({ className, children }: Props) {
22+
const classes = useStyles()
23+
return <span className={cx(classes.root, className)}>{children.substring(0, 12)}</span>
24+
}

catalog/app/constants/routes.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,26 @@ export const bucketPackageRevisions = route(
167167

168168
export type BucketPackageRevisionsArgs = Parameters<typeof bucketPackageRevisions.url>
169169

170+
interface BucketPackageCompareOpts {
171+
showAll?: boolean
172+
}
173+
174+
export const bucketPackageCompare = route(
175+
`/b/:bucket/packages/:name(${PACKAGE_PATTERN})/compare/:baseHash/:otherHash?/`,
176+
(
177+
bucket: string,
178+
name: string,
179+
base: string,
180+
other?: string,
181+
{ showAll }: BucketPackageCompareOpts = {},
182+
) =>
183+
other
184+
? `/b/${bucket}/packages/${name}/compare/${base}/${other}/${mkSearch({ showAll })}`
185+
: `/b/${bucket}/packages/${name}/compare/${base}/${mkSearch({ showAll })}`,
186+
)
187+
188+
export type BucketPackageCompareArgs = Parameters<typeof bucketPackageCompare.url>
189+
170190
export const bucketQueries = route(
171191
'/b/:bucket/queries',
172192
(bucket: string) => `/b/${bucket}/queries`,

catalog/app/containers/Bucket/Bucket.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const PackageRevisions = RT.mkLazy(
2828
() => import('./PackageRevisions'),
2929
SuspensePlaceholder,
3030
)
31+
const PackageCompare = RT.mkLazy(() => import('./PackageCompare'), SuspensePlaceholder)
3132
const PackageTree = RT.mkLazy(() => import('./PackageTree'), SuspensePlaceholder)
3233
const Queries = RT.mkLazy(() => import('./Queries'), SuspensePlaceholder)
3334
const Workflows = RT.mkLazy(() => import('./Workflows'), SuspensePlaceholder)
@@ -102,6 +103,9 @@ export default function Bucket() {
102103
<Route path={paths.bucketPackageRevisions} exact>
103104
<PackageRevisions />
104105
</Route>
106+
<Route path={paths.bucketPackageCompare} exact>
107+
<PackageCompare />
108+
</Route>
105109
<Route path={paths.bucketWorkflowList} exact>
106110
<Workflows />
107111
</Route>

catalog/app/containers/Bucket/File/File.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as M from '@material-ui/core'
77

88
import * as BreadCrumbs from 'components/BreadCrumbs'
99
import * as FileEditor from 'components/FileEditor'
10+
import * as Hash from 'components/Hash'
1011
import Message from 'components/Message'
1112
import * as Preview from 'components/Preview'
1213
import cfg from 'constants/config'
@@ -87,11 +88,7 @@ function VersionInfo({ bucket, path, version }) {
8788
<AssistantContext.VersionsContext data={data} />
8889
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
8990
<span className={classes.version} onClick={open} ref={setAnchor}>
90-
{version ? (
91-
<span className={classes.mono}>{version.substring(0, 12)}</span>
92-
) : (
93-
'latest'
94-
)}{' '}
91+
{version ? <Hash.Trimmed>{version}</Hash.Trimmed> : 'latest'}{' '}
9592
<M.Icon>expand_more</M.Icon>
9693
</span>
9794
<M.Popover
@@ -404,9 +401,7 @@ function File() {
404401
{objExists ? ( // eslint-disable-line no-nested-ternary
405402
<VersionInfo bucket={bucket} path={path} version={version} />
406403
) : version ? (
407-
<M.Box component="span" fontFamily="monospace.fontFamily">
408-
{version.substring(0, 12)}
409-
</M.Box>
404+
<Hash.Trimmed>{version}</Hash.Trimmed>
410405
) : (
411406
'latest'
412407
)}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)