mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-24 07:37:10 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 ? <>
|
||||
|
||||
@@ -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>
|
||||
|
||||
155
src/app/components/versions/VersionDiff.tsx
Normal file
155
src/app/components/versions/VersionDiff.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user