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

@@ -67,7 +67,7 @@ export function ErrorPanel({ error, prefix, reportable, onDismiss, body: body_,
return <div class="error">
{onDismiss && <div class="error-dismiss" onClick={onDismiss}>{Octicon.x}</div>}
<h3>
<h3 class="font-bold text-xl !my-[10px]">
{(prefix ?? '') + (error instanceof Error ? error.message : error)}
{stack && <span onClick={() => setStackVisible(!stackVisible)}>
{Octicon.info}

View File

@@ -13,6 +13,10 @@ export const Octicon = {
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,
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>,

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>
}

View File

@@ -2,14 +2,16 @@ import type { DataModel } from '@mcschema/core'
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
import { Analytics } from '../../Analytics.js'
import config from '../../Config.js'
import { disectFilePath, DRAFT_PROJECT, getFilePath, useLocale, useProject, useVersion } from '../../contexts/index.js'
import type { VersionId } from '../../services/index.js'
import { stringifySource } from '../../services/index.js'
import { Store } from '../../Store.js'
import { writeZip } from '../../Utils.js'
import { DRAFT_PROJECT, disectFilePath, getFilePath, useLocale, useProject, useVersion } from '../../contexts/index.js'
import { useFocus } from '../../hooks/useFocus.js'
import type { VersionId } from '../../services/index.js'
import { stringifySource } from '../../services/index.js'
import { Btn } from '../Btn.js'
import { BtnMenu } from '../BtnMenu.js'
import type { EntryAction } from '../TreeView.js'
import { Octicon } from '../Octicon.jsx'
import type { TreeViewGroupRenderer, TreeViewLeafRenderer } from '../TreeView.js'
import { TreeView } from '../TreeView.js'
interface Props {
@@ -85,12 +87,12 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
download.current.click()
}
const actions = useMemo<EntryAction[]>(() => [
const actions = useMemo(() => [
{
icon: 'pencil',
label: locale('project.rename_file'),
onAction: (e) => {
const file = disectEntry(e)
onAction: (entry: string) => {
const file = disectEntry(entry)
if (file) {
onRename(file)
}
@@ -99,8 +101,8 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
{
icon: 'trashcan',
label: locale('project.delete_file'),
onAction: (e) => {
const file = disectEntry(e)
onAction: (entry: string) => {
const file = disectEntry(entry)
if (file) {
Analytics.deleteProjectFile(file.type, projects.length, project.files.length, 'menu')
updateFile(file.type, file.id, {})
@@ -109,6 +111,32 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
},
], [disectEntry, updateFile, onRename])
const FolderEntry: TreeViewGroupRenderer = useCallback(({ name, open, onClick }) => {
return <div class="entry" onClick={onClick} >
{Octicon[!open ? 'chevron_right' : 'chevron_down']}
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{name}</span>
</div>
}, [])
const FileEntry: TreeViewLeafRenderer<string> = useCallback(({ entry }) => {
const [focused, setFocus] = useFocus()
const onContextMenu = (evt: MouseEvent) => {
evt.preventDefault()
setFocus()
}
return <div class={`entry ${entry === selected ? 'active' : ''} ${focused ? 'focused' : ''}`} onClick={() => selectFile(entry)} onContextMenu={onContextMenu} >
{Octicon.file}
<span>{entry.split('/').at(-1)}</span>
{focused && <div class="entry-menu">
{actions?.map(a => <div class="action [&>svg]:inline" onClick={e => { a.onAction(entry); e.stopPropagation(); setFocus(false) }}>
{(Octicon as any)[a.icon]}
<span>{a.label}</span>
</div>)}
</div>}
</div>
}, [actions])
return <>
<div class="project-controls">
<BtnMenu icon="chevron_down" label={project.name} tooltip={locale('switch_project')} tooltipLoc="se">
@@ -124,7 +152,7 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
<div class="file-view">
{entries.length === 0
? <span>{locale('project.no_files')}</span>
: <TreeView entries={entries} selected={selected} onSelect={selectFile} actions={actions} />}
: <TreeView entries={entries} split={path => path.split('/')} group={FolderEntry} leaf={FileEntry} />}
</div>
<a ref={download} style="display: none;"></a>
</>

View File

@@ -1,7 +1,6 @@
import { useLocale } from '../../contexts/Locale.jsx'
import { useAsync } from '../../hooks/useAsync.js'
import { fetchBugfixes } from '../../services/DataFetcher.js'
import type { VersionId } from '../../services/Schemas.js'
import { Issue } from './Issue.jsx'
interface Props {
@@ -9,7 +8,7 @@ interface Props {
}
export function IssueList({ version }: Props) {
const { locale } = useLocale()
const { value: issues, loading } = useAsync(() => fetchBugfixes(version as VersionId), [version])
const { value: issues, loading } = useAsync(() => fetchBugfixes(version), [version])
return <div class="card-column">
{issues === undefined || loading ? <>

View File

@@ -1,15 +1,14 @@
import { Link } from 'preact-router'
import { useEffect, useMemo } from 'preact/hooks'
import { useLocale } from '../../contexts/index.js'
import { useAsync } from '../../hooks/useAsync.js'
import { useSearchParam } from '../../hooks/useSearchParam.js'
import type { VersionMeta } from '../../services/index.js'
import { fetchChangelogs, getArticleLink } from '../../services/index.js'
import { Giscus } from '../Giscus.js'
import { Octicon } from '../Octicon.js'
import { ChangelogList } from './ChangelogList.js'
import { IssueList, VersionMetaData } from './index.js'
import { ChangelogList, IssueList, VersionDiff, VersionMetaData } from './index.js'
const Tabs = ['changelog', 'discussion', 'fixes']
const Tabs = ['changelog', 'diff', 'fixes']
interface Props {
id: string,
@@ -51,9 +50,9 @@ export function VersionDetail({ id, version }: Props) {
</p>}
</div>
<div class="tabs">
<span class={tab === 'changelog' ? 'selected' : ''} onClick={() => setTab('changelog')}>{locale('versions.technical_changes')}</span>
<span class={tab === 'discussion' ? 'selected' : ''} onClick={() => setTab('discussion')}>{locale('versions.discussion')}</span>
<span class={tab === 'fixes' ? 'selected' : ''} onClick={() => setTab('fixes')}>{locale('versions.fixes')}</span>
{Tabs.map(t => <Link key={t} class={tab === t ? 'selected' : ''} href={`/versions/?id=${id}&tab=${t}`}>
{locale(`versions.${t}`)}
</Link>)}
{articleLink && <a href={articleLink} target="_blank">
{locale('versions.article')}
{Octicon.link_external}
@@ -61,7 +60,7 @@ export function VersionDetail({ id, version }: Props) {
</div>
<div class="version-tab">
{tab === 'changelog' && <ChangelogList changes={filteredChangelogs} defaultOrder="asc" />}
{tab === 'discussion' && <Giscus term={`version/${id}/`} />}
{tab === 'diff' && <VersionDiff version={id} />}
{tab === 'fixes' && <IssueList version={id} />}
</div>
</div>

View File

@@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useRef } from "preact/hooks"
import { parseGitPatch } from "../../Utils.js"
import { useLocale } from "../../contexts/Locale.jsx"
import { useAsync } from "../../hooks/useAsync.js"
import { useLocalStorage } from "../../hooks/useLocalStorage.js"
import { useSearchParam } from "../../hooks/useSearchParam.js"
import { GitHubCommitFile, fetchVersionDiff } from "../../services/DataFetcher.js"
import { ErrorPanel } from "../ErrorPanel.jsx"
import { Octicon } from "../Octicon.jsx"
import { TreeView, TreeViewGroupRenderer, TreeViewLeafRenderer } from "../TreeView.jsx"
const mcmetaRawUrl = 'https://raw.githubusercontent.com/misode/mcmeta'
const mcmetaBlobUrl = 'https://github.com/misode/mcmeta/blob'
interface Props {
version: string,
}
export function VersionDiff({ version }: Props) {
const { locale } = useLocale()
const { value: commit } = useAsync(() => fetchVersionDiff(version), [version])
const diffView = useRef<HTMLDivElement>(null)
const [filename, setFilename] = useSearchParam('file')
const selectFile = useCallback((filename: string) => {
setFilename(filename)
if (diffView.current) {
const y = diffView.current.getBoundingClientRect().top + window.scrollY - 56
window.scrollTo({ top: y, behavior: 'smooth' })
}
}, [diffView, setFilename])
const { file, diff } = useMemo(() => {
if (filename === undefined) return { file: undefined, diff: undefined }
const file = commit?.files.find(f => f.filename === filename)
if (file === undefined) return { file, diff: undefined }
if (file.patch === undefined) {
const match = filename.match(/\.(png|ogg)$/)
if (match) {
return {
file,
diff: {
type: match[1],
before: file.status === 'added' ? undefined : `${mcmetaRawUrl}/${commit?.parents[0].sha}/${filename}`,
after: file.status === 'removed' ? undefined : `${mcmetaRawUrl}/${version}-diff/${filename}`,
},
}
} else if (file.status === 'renamed') {
return { file, diff: [] }
} else {
return { file, diff: new Error('Cannot display diff for this file') }
}
}
try {
return { file, diff: parseGitPatch(file.patch) }
} catch (e) {
const error = e as Error
error.message = `Failed to show diff: ${error.message}`
return { file, diff: error }
}
}, [filename, commit])
const DiffFolder: TreeViewGroupRenderer = useCallback(({ name, open, onClick }) => {
return <div class="diff-entry select-none" onClick={onClick} >
{Octicon[!open ? 'chevron_right' : 'chevron_down']}
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{name}</span>
</div>
}, [])
const DiffEntry: TreeViewLeafRenderer<GitHubCommitFile> = useCallback(({ entry }) => {
return <div class={`diff-entry py-0.5 flex items-center [&>svg]:shrink-0 select-none ${entry.filename === filename ? 'active' : ''}`} onClick={() => selectFile(entry.filename)} title={entry.filename}>
<span class="ml-[15px] mx-2 overflow-hidden text-ellipsis whitespace-nowrap">{entry.filename.split('/').at(-1)}</span>
<span class={`ml-auto diff-${entry.status}`}>{Octicon[`diff_${entry.status}`]}</span>
</div>
}, [filename])
useEffect(() => {
if (commit === undefined || filename === undefined) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const fileIndex = commit.files.findIndex(f => f.filename === filename)
const newFileIndex = fileIndex + (e.key === 'ArrowDown' ? 1 : -1)
if (newFileIndex >= 0 && newFileIndex < commit.files.length) {
selectFile(commit.files[newFileIndex].filename)
}
e.preventDefault()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [commit, filename, selectFile])
const [wrap, setWrap] = useLocalStorage('misode_diff_word_wrap', true, (s) => s === 'true', (b) => b ? 'true' : 'false')
return <>
<div class="diff-header flex items-center z-10 py-2 sticky top-[56px] md:static">
<button class={`diff-toggle mr-2 ${filename ? 'block md:hidden' : 'hidden'}`} onClick={() => setFilename(undefined)}>{Octicon.arrow_left}</button>
<p class="note">Showing <b>{commit?.files.length} changed files</b> with <b>{commit?.stats.additions} additions</b> and <b>{commit?.stats.deletions} deletions</b></p>
<div class="flex-1"></div>
{Array.isArray(diff) && <label class={`ml-2 whitespace-nowrap ${filename ? 'block' : 'hidden md:block'}`}>
<input type="checkbox" checked={wrap} onClick={() => setWrap(!wrap)} />
<span class="ml-2">{locale('version_diff.word_wrap')}</span>
</label>}
</div>
<div ref={diffView} class="w-full">
<div class={`diff-tree w-full md:w-64 md:overflow-y-scroll md:overscroll-contain md:sticky md:top-[56px] ${filename ? 'hidden md:block' : 'block'}`}>
<TreeView entries={commit?.files ?? []} group={DiffFolder} leaf={DiffEntry} split={file => file.filename.split('/')} />
</div>
{filename && <div key={filename} class={`diff-view-panel flex-1 min-w-0 md:pl-2 md:ml-64`}>
<div class="flex justify-center items-center min-w-0 text-center py-2" title={filename}>
<span class="mr-2 min-w-0 overflow-hidden text-ellipsis font-bold text-xl">{filename}</span>
<a class="diff-toggle p-1" href={`${mcmetaBlobUrl}/${version}-diff/${filename}`} target="_blank">{Octicon.link_external}</a>
</div>
{diff === undefined ? (
<span class="note">{locale('loading')}</span>
) : diff instanceof Error ? (
<ErrorPanel error={diff} />
) : !Array.isArray(diff) ? (
<div class="flex justify-center items-start px-8">
{diff.before ? (
diff.type === 'png'
? <img class="diff-media diff-media-removed w-full min-w-0" src={diff.before} alt="Before image" />
: <audio class="w-full" controls src={diff.before} />
) : (
<div class="diff-media-removed w-full self-stretch flex justify-center items-center">{Octicon.circle_slash}</div>
)}
<div class="p-2"></div>
{diff.after ? (
diff.type === 'png'
? <img class="diff-media diff-media-added w-full min-w-0" src={diff.after} alt="After image" />
: <audio class="w-full" controls src={diff.after} />
) : (
<div class="diff-media-added w-full self-stretch flex justify-center items-center">{Octicon.circle_slash}</div>
)}
</div>
) : <>
{file.previous_filename !== undefined && <div class="flex justify-center font-mono flex-wrap" title={`${file.previous_filename}${filename}`}>
<span class="overflow-hidden text-ellipsis mr-2">{file.previous_filename}</span>
<span class="overflow-hidden text-ellipsis whitespace-nowrap"><span class="select-none"> </span>{filename}</span>
</div>}
<div class={`diff-view text-sm ${wrap ? '' : 'overflow-x-auto'}`}>
<table class="max-w-full w-full">
{diff.map(line => <tr class={`w-full font-mono ${wrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} ${line.before ? (line.after ? '' : 'diff-line-removed') : (line.after ? 'diff-line-added' : 'diff-line-separation')}`}>
<td class={`diff-number ${line.before || line.after ? 'align-top' : ''} select-none px-2`}>{line.before ?? (line.after ? '' : '...')}</td>
<td class={`diff-number ${line.before || line.after ? 'align-top' : ''} select-none px-2`}>{line.after ?? (line.before ? '' : '...')}</td>
<td class="px-2 align-top w-4 select-none">{line.line.startsWith('@') ? '' : line.line.charAt(0)}</td>
<td class={`break-all w-full ${line.before || line.after ? '' : 'py-2'}`}>{line.line.startsWith('@') ? line.line : line.line.slice(1)}</td>
</tr>)}
</table>
</div>
</>}
</div>}
</div>
</>
}

View File

@@ -3,6 +3,7 @@ export * from './ChangelogEntry.js'
export * from './ChangelogList.js'
export * from './IssueList.jsx'
export * from './VersionDetail.js'
export * from './VersionDiff.jsx'
export * from './VersionEntry.js'
export * from './VersionList.js'
export * from './VersionMetaData.js'