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

@@ -545,3 +545,39 @@ export function composeMatrix(translation: Vector, leftRotation: quat, scale: Ve
.scale(scale)
.mul(Matrix4.fromQuat(rightRotation))
}
export interface PatchLine {
line: string
before?: number
after?: number
}
export function parseGitPatch(patch: string) {
const source = patch.split('\n')
const result: PatchLine[] = []
let before = 1
let after = 1
for (let i = 0; i < source.length; i += 1) {
const line = source[i]
if (line.startsWith('@')) {
const match = line.match(/^@@ -(\d+)(?:,(?:\d+))? \+(\d+)(?:,(?:\d+))? @@/)
if (!match) throw new Error(`Invalid patch pattern at line ${i+1}: ${line}`)
result.push({ line })
before = Number(match[1])
after = Number(match[2])
} else if (line.startsWith(' ')) {
result.push({ line, before, after })
before += 1
after += 1
} else if (line.startsWith('+')) {
result.push({ line, after })
after += 1
} else if (line.startsWith('-')) {
result.push({ line, before })
before += 1
} else if (!line.startsWith('\\')) {
throw new Error(`Invalid patch, got ${line.charAt(0)} at line ${i+1}`)
}
}
return result
}

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'

View File

@@ -32,7 +32,7 @@ export function useSearchParam(param: string): [string | undefined, (value: stri
} else {
params.set(param, newValue)
}
changeUrl({ search: params.toString().replaceAll('%7C', '|'), replace })
changeUrl({ search: params.toString().replaceAll('%7C', '|').replaceAll('%2F', '/'), replace })
}
}, [value])

View File

@@ -27,22 +27,20 @@ export function Versions({}: Props) {
return <main>
{error && <ErrorPanel error={error} />}
<div class="legacy-container">
{selectedId ? <>
<div class="navigation">
<BtnLink link="/versions/" icon="three_bars" label={locale('versions.all')} />
<BtnLink link={previousVersion ? `/versions/?id=${previousVersion.id}${tab ? `&tab=${tab}` : ''}` : undefined}
icon="arrow_left" label={locale('versions.previous')} />
<BtnLink link={nextVersion ? `/versions/?id=${nextVersion.id}${tab ? `&tab=${tab}` : ''}` : undefined}
icon="arrow_right" label={locale('versions.next')} swapped />
</div>
<VersionDetail id={selectedId} version={selected} />
</> : <>
<VersionList versions={versions} link={id => `/versions/?id=${id}`} navigation={(
<BtnLink link="/changelog" icon="git_commit" label={locale('versions.technical_changes')} />
)} />
</>}
</div>
{selectedId ? <div class="p-4">
<div class="navigation">
<BtnLink link="/versions/" icon="three_bars" label={locale('versions.all')} />
<BtnLink link={previousVersion ? `/versions/?id=${previousVersion.id}${tab ? `&tab=${tab}` : ''}` : undefined}
icon="arrow_left" label={locale('versions.previous')} />
<BtnLink link={nextVersion ? `/versions/?id=${nextVersion.id}${tab ? `&tab=${tab}` : ''}` : undefined}
icon="arrow_right" label={locale('versions.next')} swapped />
</div>
<VersionDetail id={selectedId} version={selected} />
</div> : <div class="legacy-container">
<VersionList versions={versions} link={id => `/versions/?id=${id}`} navigation={(
<BtnLink link="/changelog" icon="git_commit" label={locale('versions.changelog')} />
)} />
</div>}
<Footer donate={false} />
</main>
}

View File

@@ -20,7 +20,8 @@ const mcmetaUrl = 'https://raw.githubusercontent.com/misode/mcmeta'
const mcmetaTarballUrl = 'https://github.com/misode/mcmeta/tarball'
const changesUrl = 'https://raw.githubusercontent.com/misode/technical-changes'
const fixesUrl = 'https://raw.githubusercontent.com/misode/mcfixes'
const whatsNewUrl = 'https://whats-new.misode.workers.dev/'
const versionDiffUrl = 'https://mcmeta-diff.misode.workers.dev'
const whatsNewUrl = 'https://whats-new.misode.workers.dev'
type McmetaTypes = 'summary' | 'data' | 'data-json' | 'assets' | 'assets-json' | 'registries' | 'atlas'
@@ -273,12 +274,46 @@ export interface Bugfix {
votes: number,
}
export async function fetchBugfixes(version: VersionId): Promise<Bugfix[]> {
export async function fetchBugfixes(version: string): Promise<Bugfix[]> {
try {
const fixes = await cachedFetch<Bugfix[]>(`${fixesUrl}/main/versions/${version}.json`, { refresh: true })
return fixes
} catch (e) {
throw new Error(`Error occured while fetching bugfixes: ${message(e)}`)
throw new Error(`Error occured while fetching bugfixes for version ${version}: ${message(e)}`)
}
}
export interface GitHubCommitFile {
sha: string,
filename: string,
previous_filename?: string,
status: 'added' | 'modified' | 'removed' | 'renamed',
additions: number,
deletions: number,
changes: number,
patch: string,
}
export interface GitHubCommit {
sha: string,
html_url: string,
parents: {
sha: string,
}[],
stats: {
total: number,
additions: number,
deletions: number,
},
files: GitHubCommitFile[],
}
export async function fetchVersionDiff(version: string) {
try {
const diff = await cachedFetch<GitHubCommit>(`${versionDiffUrl}/${version}`, { refresh: true })
return diff
} catch (e) {
throw new Error(`Error occured while fetching diff for version ${version}: ${message(e)}`)
}
}

View File

@@ -247,13 +247,14 @@
"versions.pack_format": "Pack format",
"versions.data_pack_format": "Data pack format",
"versions.resource_pack_format": "Resource pack format",
"versions.technical_changes": "Technical changes",
"versions.discussion": "Discussion",
"versions.changelog": "Technical changes",
"versions.diff": "Mcmeta diff",
"versions.fixes": "Fixed bugs",
"versions.fixes.no_results": "No fixes",
"versions.minecraft_versions": "Minecraft Versions",
"versions.latest_snapshot": "Latest snapshot",
"versions.latest_release": "Latest release",
"version_diff.word_wrap": "Word wrap",
"weight": "Weight",
"whats_new": "What's new?",
"whats_new.description": "Stay informed about all the latest development on misode.github.io. Read below to find out which features have recently been added.",

View File

@@ -201,7 +201,7 @@
"versions.released": "Sortie",
"versions.resource_pack_format": "Format du pack de ressources",
"versions.search": "Rechercher des versions",
"versions.technical_changes": "Modifications techniques",
"versions.changelog": "Modifications techniques",
"world": "Paramètres du monde",
"worldgen": "Générateur de monde",
"worldgen/biome": "Biome",

View File

@@ -200,7 +200,7 @@
"versions.released": "リリース済み",
"versions.resource_pack_format": "リソースパック形式",
"versions.search": "バージョンを検索",
"versions.technical_changes": "技術的な変更点",
"versions.changelog": "技術的な変更点",
"world": "ワールド設定 (World Settings)",
"worldgen": "ワールドジェネレーター (World Generator)",
"worldgen/biome": "バイオーム (Biome)",

View File

@@ -186,7 +186,7 @@
"versions.released": "릴리즈됨",
"versions.resource_pack_format": "리소스 팩 형식",
"versions.search": "버전 검색",
"versions.technical_changes": "기술적 변경",
"versions.changelog": "기술적 변경",
"world": "월드 설정",
"worldgen": "월드젠",
"worldgen/biome": "바이옴",

View File

@@ -214,7 +214,7 @@
"versions.pack_format": "Формат набора",
"versions.data_pack_format": "Формат набора данных",
"versions.resource_pack_format": "Формат набора ресурсов",
"versions.technical_changes": "Технические изменения",
"versions.changelog": "Технические изменения",
"versions.discussion": "Обсуждение",
"versions.fixes": "Исправленные ошибки",
"versions.fixes.no_results": "Нет исправлений",

View File

@@ -200,7 +200,7 @@
"versions.released": "发布于",
"versions.resource_pack_format": "资源包格式",
"versions.search": "搜索版本",
"versions.technical_changes": "技术变更",
"versions.changelog": "技术变更",
"world": "世界设置",
"worldgen": "世界生成",
"worldgen/biome": "生物群系",

View File

@@ -38,6 +38,16 @@
--editor-string: #CE9178;
--editor-constant: #569CD6;
--editor-number: #B5CEA8;
--diff-added: #3fb950;
--diff-modified: #d29922;
--diff-removed: #f85149;
--diff-renamed: #7d8590;
--diff-line-added: rgba(46, 160, 67, 0.15);
--diff-line-removed: rgba(248, 81, 73, 0.1);
--diff-line-separation: rgba(56, 139, 253, 0.1);
--diff-numbers-added: rgba(63, 185, 80, 0.3);
--diff-numbers-removed: rgba(248, 81, 73, 0.3);
--diff-numbers-separation: rgba(56, 139, 253, 0.4);
}
:root[data-theme=light] {
@@ -80,6 +90,16 @@
--editor-string: #A31515;
--editor-constant: #0000FF;
--editor-number: #098658;
--diff-added: #1a7f37;
--diff-modified: #9a6700;
--diff-removed: #d1242f;
--diff-renamed: #656d76;
--diff-line-added: rgb(230, 255, 236);
--diff-line-removed: rgb(255, 235, 233);
--diff-line-separation: rgb(221, 244, 255);
--diff-numbers-added: rgb(204, 255, 216);
--diff-numbers-removed: rgb(255, 215, 213);
--diff-numbers-separation: rgba(84, 174, 255, 0.4);
}
@media (prefers-color-scheme: light) {
@@ -123,6 +143,16 @@
--editor-string: #A31515;
--editor-constant: #0000FF;
--editor-number: #098658;
--diff-added: #1a7f37;
--diff-modified: #9a6700;
--diff-removed: #d1242f;
--diff-renamed: #656d76;
--diff-line-added: rgb(230, 255, 236);
--diff-line-removed: rgb(255, 235, 233);
--diff-line-separation: rgb(221, 244, 255);
--diff-numbers-added: rgb(204, 255, 216);
--diff-numbers-removed: rgb(255, 215, 213);
--diff-numbers-separation: rgba(84, 174, 255, 0.4);
}
}
@@ -2350,6 +2380,123 @@ hr {
margin-top: 20px;
}
.diff-header {
background-color: var(--background-1);
}
.diff-toggle {
fill: var(--text-3);
}
.diff-toggle:hover {
fill: var(--text-1);
}
@media (min-width: 768px) {
.diff-tree {
max-height: calc(100vh - 56px);
min-height: calc(100vh - 56px);
}
.diff-view-panel {
margin-top: calc(-100vh + 56px);
min-height: calc(100vh - 56px);
}
}
.diff-entry {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
padding: 4px 8px 4px 6px;
padding-left: calc(var(--indent, 0) * 8px);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
color: var(--text-2);
border-left: 2px solid transparent;
}
.diff-entry:hover {
background-color: var(--background-2);
}
.diff-entry.active {
background-color: var(--background-4);
border-color: var(--accent-primary);
}
.diff-added {
fill: var(--diff-added);
}
.diff-modified {
fill: var(--diff-modified);
}
.diff-removed {
fill: var(--diff-removed);
}
.diff-renamed {
fill: var(--diff-renamed);
}
.diff-view {
background-color: var(--background-1);
border: 1px solid var(--background-4);
border-left: none;
border-right: none;
}
.diff-line-added {
background-color: var(--diff-line-added);
color: var(--text-1);
}
.diff-line-removed {
background-color: var(--diff-line-removed);
color: var(--text-1);
}
.diff-line-separation {
background-color: var(--diff-line-separation);
}
.diff-number {
text-align: right;
}
.diff-line-added .diff-number {
background-color: var(--diff-numbers-added);
color: var(--text-1);
}
.diff-line-removed .diff-number {
background-color: var(--diff-numbers-removed);
color: var(--text-2);
}
.diff-line-separation .diff-number {
background-color: var(--diff-numbers-separation);
text-align: center;
}
.diff-media {
image-rendering: pixelated;
background-color: var(--background-2);
}
.diff-media-added {
background-color: var(--diff-line-added);
}
.diff-media-removed {
background-color: var(--diff-line-removed);
}
.tabs {
display: flex;
margin-bottom: 10px;