mirror of
https://github.com/misode/misode.github.io.git
synced 2026-04-23 07:10:41 +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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
"versions.released": "릴리즈됨",
|
||||
"versions.resource_pack_format": "리소스 팩 형식",
|
||||
"versions.search": "버전 검색",
|
||||
"versions.technical_changes": "기술적 변경",
|
||||
"versions.changelog": "기술적 변경",
|
||||
"world": "월드 설정",
|
||||
"worldgen": "월드젠",
|
||||
"worldgen/biome": "바이옴",
|
||||
|
||||
@@ -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": "Нет исправлений",
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
"versions.released": "发布于",
|
||||
"versions.resource_pack_format": "资源包格式",
|
||||
"versions.search": "搜索版本",
|
||||
"versions.technical_changes": "技术变更",
|
||||
"versions.changelog": "技术变更",
|
||||
"world": "世界设置",
|
||||
"worldgen": "世界生成",
|
||||
"worldgen/biome": "生物群系",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user