Skip to content

Commit ddd00dd

Browse files
authored
Add version mcmeta diff page (#428)
* Add version mcmeta diff page * Add toggle for word wrapping * Fix diff view on mobile * Use full layout width on version details * Show image and audio diffs * Add word_wrap locale
1 parent ddf5417 commit ddd00dd

File tree

19 files changed

+474
-119
lines changed

19 files changed

+474
-119
lines changed

src/app/Utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,39 @@ export function composeMatrix(translation: Vector, leftRotation: quat, scale: Ve
545545
.scale(scale)
546546
.mul(Matrix4.fromQuat(rightRotation))
547547
}
548+
549+
export interface PatchLine {
550+
line: string
551+
before?: number
552+
after?: number
553+
}
554+
555+
export function parseGitPatch(patch: string) {
556+
const source = patch.split('\n')
557+
const result: PatchLine[] = []
558+
let before = 1
559+
let after = 1
560+
for (let i = 0; i < source.length; i += 1) {
561+
const line = source[i]
562+
if (line.startsWith('@')) {
563+
const match = line.match(/^@@ -(\d+)(?:,(?:\d+))? \+(\d+)(?:,(?:\d+))? @@/)
564+
if (!match) throw new Error(`Invalid patch pattern at line ${i+1}: ${line}`)
565+
result.push({ line })
566+
before = Number(match[1])
567+
after = Number(match[2])
568+
} else if (line.startsWith(' ')) {
569+
result.push({ line, before, after })
570+
before += 1
571+
after += 1
572+
} else if (line.startsWith('+')) {
573+
result.push({ line, after })
574+
after += 1
575+
} else if (line.startsWith('-')) {
576+
result.push({ line, before })
577+
before += 1
578+
} else if (!line.startsWith('\\')) {
579+
throw new Error(`Invalid patch, got ${line.charAt(0)} at line ${i+1}`)
580+
}
581+
}
582+
return result
583+
}

src/app/components/ErrorPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_,
6767

6868
return <div class="error">
6969
{onDismiss && <div class="error-dismiss" onClick={onDismiss}>{Octicon.x}</div>}
70-
<h3>
70+
<h3 class="font-bold text-xl !my-[10px]">
7171
{(prefix ?? '') + (error instanceof Error ? error.message : error)}
7272
{stack && <span onClick={() => setStackVisible(!stackVisible)}>
7373
{Octicon.info}

src/app/components/Octicon.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export const Octicon = {
1313
clippy: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zm.75 3V2.5h3V4h-3zm-2.874-.467a.75.75 0 00-.752-1.298A1.75 1.75 0 002 3.75v9.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-9.5a1.75 1.75 0 00-.874-1.515.75.75 0 10-.752 1.298.25.25 0 01.126.217v9.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-9.5a.25.25 0 01.126-.217z"></path></svg>,
1414
code: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 10-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 101.06 1.06l4.25-4.25a.75.75 0 000-1.06l-4.25-4.25z"></path></svg>,
1515
codescan_checkmark: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M10.28 6.28a.75.75 0 10-1.06-1.06L6.25 8.19l-.97-.97a.75.75 0 00-1.06 1.06l1.5 1.5a.75.75 0 001.06 0l3.5-3.5z"></path><path fill-rule="evenodd" d="M7.5 15a7.469 7.469 0 004.746-1.693l2.474 2.473a.75.75 0 101.06-1.06l-2.473-2.474A7.5 7.5 0 107.5 15zm0-13.5a6 6 0 104.094 10.386.75.75 0 01.293-.292A6 6 0 007.5 1.5z"></path></svg>,
16+
diff_added: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M2.75 1h10.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1Zm10.5 1.5H2.75a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM8 4a.75.75 0 0 1 .75.75v2.5h2.5a.75.75 0 0 1 0 1.5h-2.5v2.5a.75.75 0 0 1-1.5 0v-2.5h-2.5a.75.75 0 0 1 0-1.5h2.5v-2.5A.75.75 0 0 1 8 4Z"></path></svg>,
17+
diff_modified: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"></path></svg>,
18+
diff_removed: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm8.5 6.25h-6.5a.75.75 0 0 1 0-1.5h6.5a.75.75 0 0 1 0 1.5Z"></path></svg>,
19+
diff_renamed: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M13.25 1c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25V2.75C1 1.784 1.784 1 2.75 1ZM2.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25Zm9.03 6.03-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H4.75a.75.75 0 0 1 0-1.5h4.69L7.47 5.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l3.25 3.25a.75.75 0 0 1 0 1.06Z"></path></svg>,
1620
dash: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2 8a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8z"></path></svg>,
1721
device_desktop: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 2.5h12.5a.25.25 0 01.25.25v7.5a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25v-7.5a.25.25 0 01.25-.25zM14.25 1H1.75A1.75 1.75 0 000 2.75v7.5C0 11.216.784 12 1.75 12h3.727c-.1 1.041-.52 1.872-1.292 2.757A.75.75 0 004.75 16h6.5a.75.75 0 00.565-1.243c-.772-.885-1.193-1.716-1.292-2.757h3.727A1.75 1.75 0 0016 10.25v-7.5A1.75 1.75 0 0014.25 1zM9.018 12H6.982a5.72 5.72 0 01-.765 2.5h3.566a5.72 5.72 0 01-.765-2.5z"></path></svg>,
1822
dot_fill: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8z"></path></svg>,

src/app/components/TreeView.tsx

Lines changed: 23 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,31 @@
11
import { useMemo, useState } from 'preact/hooks'
2-
import { useFocus } from '../hooks/index.js'
3-
import { Octicon } from './index.js'
42

5-
const SEPARATOR = '/'
3+
export type TreeViewGroupRenderer = (props: { name: string, open: boolean, onClick: () => void }) => JSX.Element
4+
export type TreeViewLeafRenderer<E> = (props: { entry: E }) => JSX.Element
65

7-
export interface EntryAction {
8-
icon: keyof typeof Octicon,
9-
label: string,
10-
onAction: (entry: string) => unknown,
6+
interface Props<E> {
7+
entries: E[],
8+
split: (entry: E) => string[],
9+
group: TreeViewGroupRenderer,
10+
leaf: TreeViewLeafRenderer<E>,
11+
level?: number,
1112
}
1213

13-
export interface EntryError {
14-
path: string,
15-
message: string,
16-
}
17-
18-
interface Props {
19-
entries: string[],
20-
onSelect: (entry: string) => unknown,
21-
selected?: string,
22-
actions?: EntryAction[],
23-
errors?: EntryError[],
24-
indent?: number,
25-
}
26-
export function TreeView({ entries, onSelect, selected, actions, errors, indent }: Props) {
14+
export function TreeView<E>({ entries, split, group: Group, leaf: Leaf, level = 0 }: Props<E>) {
2715
const roots = useMemo(() => {
28-
const groups: Record<string, string[]> = {}
16+
const groups: Record<string, E[]> = {}
2917
for (const entry of entries) {
30-
const i = entry.indexOf(SEPARATOR)
31-
if (i >= 0) {
32-
const root = entry.slice(0, i)
33-
;(groups[root] ??= []).push(entry.slice(i + 1))
18+
const path = split(entry)
19+
if (path[level + 1] !== undefined) {
20+
;(groups[path[level]] ??= []).push(entry)
3421
}
3522
}
36-
return Object.entries(groups).map(([r, entries]) => {
37-
const rootActions = actions?.map(a => ({ ...a, onAction: (e: string) => a.onAction(r + SEPARATOR + e) }))
38-
const rootErrors = errors?.flatMap(e => e.path.startsWith(r + SEPARATOR) ? [{ ...e, path: e.path.slice(r.length + SEPARATOR.length) }] : [])
39-
return [r, entries, rootActions, rootErrors] as [string, string[], EntryAction[], EntryError[]]
40-
}).sort()
41-
}, [entries, actions, errors])
23+
return groups
24+
}, [entries, split, level])
4225

4326
const leaves = useMemo(() => {
44-
return entries.filter(e => !e.includes(SEPARATOR))
45-
}, [entries])
27+
return entries.filter(e => split(e).length === level + 1)
28+
}, [entries, split, level])
4629

4730
const [hidden, setHidden] = useState(new Set<string>())
4831
const toggle = (root: string) => {
@@ -54,43 +37,12 @@ export function TreeView({ entries, onSelect, selected, actions, errors, indent
5437
setHidden(new Set(hidden))
5538
}
5639

57-
return <div class="tree-view" style={`--indent: ${indent ?? 0};`}>
58-
{roots.map(([r, entries, actions, errors]) => <div>
59-
<TreeViewEntry icon={hidden.has(r) ? 'chevron_right' : 'chevron_down'} key={r} label={r} onClick={() => toggle(r)} error={(errors?.length ?? 0) > 0} />
40+
return <div class="tree-view" style={`--indent: ${level};`}>
41+
{Object.entries(roots).map(([r, childs]) => <>
42+
<Group name={r} open={!hidden.has(r)} onClick={() => toggle(r)} />
6043
{!hidden.has(r) &&
61-
<TreeView entries={entries} onSelect={e => onSelect(`${r}${SEPARATOR}${e}`)}
62-
selected={selected?.startsWith(r + SEPARATOR) ? selected.substring(r.length + 1) : undefined}
63-
actions={actions} errors={errors} indent={(indent ?? 0) + 1} />}
64-
</div>)}
65-
{leaves.map(e => <TreeViewEntry icon="file" key={e} label={e} active={e === selected} onClick={() => onSelect(e)} actions={actions?.map(a => ({ ...a, onAction: () => a.onAction(e) }))} error={errors?.find(er => er.path === e)?.message} />)}
66-
</div>
67-
}
68-
69-
interface TreeViewEntryProps {
70-
icon: keyof typeof Octicon,
71-
label: string,
72-
active?: boolean,
73-
onClick?: () => unknown,
74-
actions?: EntryAction[],
75-
error?: string | boolean,
76-
}
77-
function TreeViewEntry({ icon, label, active, onClick, actions, error }: TreeViewEntryProps) {
78-
const [focused, setFocus] = useFocus()
79-
const onContextMenu = (evt: MouseEvent) => {
80-
evt.preventDefault()
81-
if (actions?.length) {
82-
setFocus()
83-
}
84-
}
85-
86-
return <div class={`entry${error ? ' has-error' : ''}${active ? ' active' : ''}${focused ? ' focused' : ''}`} onClick={onClick} onContextMenu={onContextMenu} >
87-
{Octicon[icon]}
88-
<span>{label.replaceAll('\u2215', '/')}</span>
89-
{typeof error === 'string' && <div class="status-icon danger tooltipped tip-se" aria-label={error}>
90-
{Octicon.issue_opened}
91-
</div>}
92-
{focused && <div class="entry-menu">
93-
{actions?.map(a => <div class="action" onClick={e => { a.onAction(''); e.stopPropagation(); setFocus(false) }}>{Octicon[a.icon]}{a.label}</div>)}
94-
</div>}
44+
<TreeView<E> entries={childs} split={split} group={Group} leaf={Leaf} level={level + 1} />}
45+
</>)}
46+
{leaves.map(e => <Leaf key={split(e).join('/')} entry={e} />)}
9547
</div>
9648
}

src/app/components/generator/ProjectPanel.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import type { DataModel } from '@mcschema/core'
22
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
33
import { Analytics } from '../../Analytics.js'
44
import config from '../../Config.js'
5-
import { disectFilePath, DRAFT_PROJECT, getFilePath, useLocale, useProject, useVersion } from '../../contexts/index.js'
6-
import type { VersionId } from '../../services/index.js'
7-
import { stringifySource } from '../../services/index.js'
85
import { Store } from '../../Store.js'
96
import { writeZip } from '../../Utils.js'
7+
import { DRAFT_PROJECT, disectFilePath, getFilePath, useLocale, useProject, useVersion } from '../../contexts/index.js'
8+
import { useFocus } from '../../hooks/useFocus.js'
9+
import type { VersionId } from '../../services/index.js'
10+
import { stringifySource } from '../../services/index.js'
1011
import { Btn } from '../Btn.js'
1112
import { BtnMenu } from '../BtnMenu.js'
12-
import type { EntryAction } from '../TreeView.js'
13+
import { Octicon } from '../Octicon.jsx'
14+
import type { TreeViewGroupRenderer, TreeViewLeafRenderer } from '../TreeView.js'
1315
import { TreeView } from '../TreeView.js'
1416

1517
interface Props {
@@ -85,12 +87,12 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
8587
download.current.click()
8688
}
8789

88-
const actions = useMemo<EntryAction[]>(() => [
90+
const actions = useMemo(() => [
8991
{
9092
icon: 'pencil',
9193
label: locale('project.rename_file'),
92-
onAction: (e) => {
93-
const file = disectEntry(e)
94+
onAction: (entry: string) => {
95+
const file = disectEntry(entry)
9496
if (file) {
9597
onRename(file)
9698
}
@@ -99,8 +101,8 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
99101
{
100102
icon: 'trashcan',
101103
label: locale('project.delete_file'),
102-
onAction: (e) => {
103-
const file = disectEntry(e)
104+
onAction: (entry: string) => {
105+
const file = disectEntry(entry)
104106
if (file) {
105107
Analytics.deleteProjectFile(file.type, projects.length, project.files.length, 'menu')
106108
updateFile(file.type, file.id, {})
@@ -109,6 +111,32 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
109111
},
110112
], [disectEntry, updateFile, onRename])
111113

114+
const FolderEntry: TreeViewGroupRenderer = useCallback(({ name, open, onClick }) => {
115+
return <div class="entry" onClick={onClick} >
116+
{Octicon[!open ? 'chevron_right' : 'chevron_down']}
117+
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{name}</span>
118+
</div>
119+
}, [])
120+
121+
const FileEntry: TreeViewLeafRenderer<string> = useCallback(({ entry }) => {
122+
const [focused, setFocus] = useFocus()
123+
const onContextMenu = (evt: MouseEvent) => {
124+
evt.preventDefault()
125+
setFocus()
126+
}
127+
128+
return <div class={`entry ${entry === selected ? 'active' : ''} ${focused ? 'focused' : ''}`} onClick={() => selectFile(entry)} onContextMenu={onContextMenu} >
129+
{Octicon.file}
130+
<span>{entry.split('/').at(-1)}</span>
131+
{focused && <div class="entry-menu">
132+
{actions?.map(a => <div class="action [&>svg]:inline" onClick={e => { a.onAction(entry); e.stopPropagation(); setFocus(false) }}>
133+
{(Octicon as any)[a.icon]}
134+
<span>{a.label}</span>
135+
</div>)}
136+
</div>}
137+
</div>
138+
}, [actions])
139+
112140
return <>
113141
<div class="project-controls">
114142
<BtnMenu icon="chevron_down" label={project.name} tooltip={locale('switch_project')} tooltipLoc="se">
@@ -124,7 +152,7 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
124152
<div class="file-view">
125153
{entries.length === 0
126154
? <span>{locale('project.no_files')}</span>
127-
: <TreeView entries={entries} selected={selected} onSelect={selectFile} actions={actions} />}
155+
: <TreeView entries={entries} split={path => path.split('/')} group={FolderEntry} leaf={FileEntry} />}
128156
</div>
129157
<a ref={download} style="display: none;"></a>
130158
</>

src/app/components/versions/IssueList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { useLocale } from '../../contexts/Locale.jsx'
22
import { useAsync } from '../../hooks/useAsync.js'
33
import { fetchBugfixes } from '../../services/DataFetcher.js'
4-
import type { VersionId } from '../../services/Schemas.js'
54
import { Issue } from './Issue.jsx'
65

76
interface Props {
87
version: string
98
}
109
export function IssueList({ version }: Props) {
1110
const { locale } = useLocale()
12-
const { value: issues, loading } = useAsync(() => fetchBugfixes(version as VersionId), [version])
11+
const { value: issues, loading } = useAsync(() => fetchBugfixes(version), [version])
1312

1413
return <div class="card-column">
1514
{issues === undefined || loading ? <>

src/app/components/versions/VersionDetail.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1+
import { Link } from 'preact-router'
12
import { useEffect, useMemo } from 'preact/hooks'
23
import { useLocale } from '../../contexts/index.js'
34
import { useAsync } from '../../hooks/useAsync.js'
45
import { useSearchParam } from '../../hooks/useSearchParam.js'
56
import type { VersionMeta } from '../../services/index.js'
67
import { fetchChangelogs, getArticleLink } from '../../services/index.js'
7-
import { Giscus } from '../Giscus.js'
88
import { Octicon } from '../Octicon.js'
9-
import { ChangelogList } from './ChangelogList.js'
10-
import { IssueList, VersionMetaData } from './index.js'
9+
import { ChangelogList, IssueList, VersionDiff, VersionMetaData } from './index.js'
1110

12-
const Tabs = ['changelog', 'discussion', 'fixes']
11+
const Tabs = ['changelog', 'diff', 'fixes']
1312

1413
interface Props {
1514
id: string,
@@ -51,17 +50,17 @@ export function VersionDetail({ id, version }: Props) {
5150
</p>}
5251
</div>
5352
<div class="tabs">
54-
<span class={tab === 'changelog' ? 'selected' : ''} onClick={() => setTab('changelog')}>{locale('versions.technical_changes')}</span>
55-
<span class={tab === 'discussion' ? 'selected' : ''} onClick={() => setTab('discussion')}>{locale('versions.discussion')}</span>
56-
<span class={tab === 'fixes' ? 'selected' : ''} onClick={() => setTab('fixes')}>{locale('versions.fixes')}</span>
53+
{Tabs.map(t => <Link key={t} class={tab === t ? 'selected' : ''} href={`/versions/?id=${id}&tab=${t}`}>
54+
{locale(`versions.${t}`)}
55+
</Link>)}
5756
{articleLink && <a href={articleLink} target="_blank">
5857
{locale('versions.article')}
5958
{Octicon.link_external}
6059
</a>}
6160
</div>
6261
<div class="version-tab">
6362
{tab === 'changelog' && <ChangelogList changes={filteredChangelogs} defaultOrder="asc" />}
64-
{tab === 'discussion' && <Giscus term={`version/${id}/`} />}
63+
{tab === 'diff' && <VersionDiff version={id} />}
6564
{tab === 'fixes' && <IssueList version={id} />}
6665
</div>
6766
</div>

0 commit comments

Comments
 (0)