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
This commit is contained in:
Misode
2023-10-09 22:09:36 +02:00
committed by GitHub
parent ddf54174d1
commit ddd00dd731
19 changed files with 474 additions and 119 deletions

View File

@@ -1,48 +1,31 @@
import { useMemo, useState } from 'preact/hooks'
import { useFocus } from '../hooks/index.js'
import { Octicon } from './index.js'
const SEPARATOR = '/'
export type TreeViewGroupRenderer = (props: { name: string, open: boolean, onClick: () => void }) => JSX.Element
export type TreeViewLeafRenderer<E> = (props: { entry: E }) => JSX.Element
export interface EntryAction {
icon: keyof typeof Octicon,
label: string,
onAction: (entry: string) => unknown,
interface Props<E> {
entries: E[],
split: (entry: E) => string[],
group: TreeViewGroupRenderer,
leaf: TreeViewLeafRenderer<E>,
level?: number,
}
export interface EntryError {
path: string,
message: string,
}
interface Props {
entries: string[],
onSelect: (entry: string) => unknown,
selected?: string,
actions?: EntryAction[],
errors?: EntryError[],
indent?: number,
}
export function TreeView({ entries, onSelect, selected, actions, errors, indent }: Props) {
export function TreeView<E>({ entries, split, group: Group, leaf: Leaf, level = 0 }: Props<E>) {
const roots = useMemo(() => {
const groups: Record<string, string[]> = {}
const groups: Record<string, E[]> = {}
for (const entry of entries) {
const i = entry.indexOf(SEPARATOR)
if (i >= 0) {
const root = entry.slice(0, i)
;(groups[root] ??= []).push(entry.slice(i + 1))
const path = split(entry)
if (path[level + 1] !== undefined) {
;(groups[path[level]] ??= []).push(entry)
}
}
return Object.entries(groups).map(([r, entries]) => {
const rootActions = actions?.map(a => ({ ...a, onAction: (e: string) => a.onAction(r + SEPARATOR + e) }))
const rootErrors = errors?.flatMap(e => e.path.startsWith(r + SEPARATOR) ? [{ ...e, path: e.path.slice(r.length + SEPARATOR.length) }] : [])
return [r, entries, rootActions, rootErrors] as [string, string[], EntryAction[], EntryError[]]
}).sort()
}, [entries, actions, errors])
return groups
}, [entries, split, level])
const leaves = useMemo(() => {
return entries.filter(e => !e.includes(SEPARATOR))
}, [entries])
return entries.filter(e => split(e).length === level + 1)
}, [entries, split, level])
const [hidden, setHidden] = useState(new Set<string>())
const toggle = (root: string) => {
@@ -54,43 +37,12 @@ export function TreeView({ entries, onSelect, selected, actions, errors, indent
setHidden(new Set(hidden))
}
return <div class="tree-view" style={`--indent: ${indent ?? 0};`}>
{roots.map(([r, entries, actions, errors]) => <div>
<TreeViewEntry icon={hidden.has(r) ? 'chevron_right' : 'chevron_down'} key={r} label={r} onClick={() => toggle(r)} error={(errors?.length ?? 0) > 0} />
return <div class="tree-view" style={`--indent: ${level};`}>
{Object.entries(roots).map(([r, childs]) => <>
<Group name={r} open={!hidden.has(r)} onClick={() => toggle(r)} />
{!hidden.has(r) &&
<TreeView entries={entries} onSelect={e => onSelect(`${r}${SEPARATOR}${e}`)}
selected={selected?.startsWith(r + SEPARATOR) ? selected.substring(r.length + 1) : undefined}
actions={actions} errors={errors} indent={(indent ?? 0) + 1} />}
</div>)}
{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} />)}
</div>
}
interface TreeViewEntryProps {
icon: keyof typeof Octicon,
label: string,
active?: boolean,
onClick?: () => unknown,
actions?: EntryAction[],
error?: string | boolean,
}
function TreeViewEntry({ icon, label, active, onClick, actions, error }: TreeViewEntryProps) {
const [focused, setFocus] = useFocus()
const onContextMenu = (evt: MouseEvent) => {
evt.preventDefault()
if (actions?.length) {
setFocus()
}
}
return <div class={`entry${error ? ' has-error' : ''}${active ? ' active' : ''}${focused ? ' focused' : ''}`} onClick={onClick} onContextMenu={onContextMenu} >
{Octicon[icon]}
<span>{label.replaceAll('\u2215', '/')}</span>
{typeof error === 'string' && <div class="status-icon danger tooltipped tip-se" aria-label={error}>
{Octicon.issue_opened}
</div>}
{focused && <div class="entry-menu">
{actions?.map(a => <div class="action" onClick={e => { a.onAction(''); e.stopPropagation(); setFocus(false) }}>{Octicon[a.icon]}{a.label}</div>)}
</div>}
<TreeView<E> entries={childs} split={split} group={Group} leaf={Leaf} level={level + 1} />}
</>)}
{leaves.map(e => <Leaf key={split(e).join('/')} entry={e} />)}
</div>
}