- {roots.map(([r, entries, actions, errors]) =>
-
toggle(r)} error={(errors?.length ?? 0) > 0} />
+ return
+ {Object.entries(roots).map(([r, childs]) => <>
+ toggle(r)} />
{!hidden.has(r) &&
- onSelect(`${r}${SEPARATOR}${e}`)}
- selected={selected?.startsWith(r + SEPARATOR) ? selected.substring(r.length + 1) : undefined}
- actions={actions} errors={errors} indent={(indent ?? 0) + 1} />}
-
)}
- {leaves.map(e => onSelect(e)} actions={actions?.map(a => ({ ...a, onAction: () => a.onAction(e) }))} error={errors?.find(er => er.path === e)?.message} />)}
-
-}
-
-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
- {Octicon[icon]}
-
{label.replaceAll('\u2215', '/')}
- {typeof error === 'string' &&
- {Octicon.issue_opened}
-
}
- {focused && }
+
entries={childs} split={split} group={Group} leaf={Leaf} level={level + 1} />}
+ >)}
+ {leaves.map(e => )}
}
diff --git a/src/app/components/generator/ProjectPanel.tsx b/src/app/components/generator/ProjectPanel.tsx
index 8c22e1be..390e00a4 100644
--- a/src/app/components/generator/ProjectPanel.tsx
+++ b/src/app/components/generator/ProjectPanel.tsx
@@ -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
(() => [
+ 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
+ {Octicon[!open ? 'chevron_right' : 'chevron_down']}
+ {name}
+
+ }, [])
+
+ const FileEntry: TreeViewLeafRenderer = useCallback(({ entry }) => {
+ const [focused, setFocus] = useFocus()
+ const onContextMenu = (evt: MouseEvent) => {
+ evt.preventDefault()
+ setFocus()
+ }
+
+ return selectFile(entry)} onContextMenu={onContextMenu} >
+ {Octicon.file}
+ {entry.split('/').at(-1)}
+ {focused && }
+
+ }, [actions])
+
return <>
@@ -124,7 +152,7 @@ export function ProjectPanel({ onRename, onCreate, onDeleteProject }: Props) {
{entries.length === 0
? {locale('project.no_files')}
- : }
+ : path.split('/')} group={FolderEntry} leaf={FileEntry} />}
>
diff --git a/src/app/components/versions/IssueList.tsx b/src/app/components/versions/IssueList.tsx
index 2b49082f..3a42b2dd 100644
--- a/src/app/components/versions/IssueList.tsx
+++ b/src/app/components/versions/IssueList.tsx
@@ -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
{issues === undefined || loading ? <>
diff --git a/src/app/components/versions/VersionDetail.tsx b/src/app/components/versions/VersionDetail.tsx
index 3681e548..50ad58ca 100644
--- a/src/app/components/versions/VersionDetail.tsx
+++ b/src/app/components/versions/VersionDetail.tsx
@@ -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) {
}
{tab === 'changelog' && }
- {tab === 'discussion' && }
+ {tab === 'diff' && }
{tab === 'fixes' && }
diff --git a/src/app/components/versions/VersionDiff.tsx b/src/app/components/versions/VersionDiff.tsx
new file mode 100644
index 00000000..ca18ea90
--- /dev/null
+++ b/src/app/components/versions/VersionDiff.tsx
@@ -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(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
+ {Octicon[!open ? 'chevron_right' : 'chevron_down']}
+ {name}
+
+ }, [])
+
+ const DiffEntry: TreeViewLeafRenderer = useCallback(({ entry }) => {
+ return svg]:shrink-0 select-none ${entry.filename === filename ? 'active' : ''}`} onClick={() => selectFile(entry.filename)} title={entry.filename}>
+ {entry.filename.split('/').at(-1)}
+ {Octicon[`diff_${entry.status}`]}
+
+ }, [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 <>
+
+
+
+ file.filename.split('/')} />
+
+ {filename &&
+
+ {diff === undefined ? (
+
{locale('loading')}
+ ) : diff instanceof Error ? (
+
+ ) : !Array.isArray(diff) ? (
+
+ {diff.before ? (
+ diff.type === 'png'
+ ?

+ :
+ ) : (
+
{Octicon.circle_slash}
+ )}
+
+ {diff.after ? (
+ diff.type === 'png'
+ ?

+ :
+ ) : (
+
{Octicon.circle_slash}
+ )}
+
+ ) : <>
+ {file.previous_filename !== undefined &&
+ {file.previous_filename}
+ → {filename}
+
}
+
+
+ {diff.map(line =>
+ | {line.before ?? (line.after ? '' : '...')} |
+ {line.after ?? (line.before ? '' : '...')} |
+ {line.line.startsWith('@') ? '' : line.line.charAt(0)} |
+ {line.line.startsWith('@') ? line.line : line.line.slice(1)} |
+
)}
+
+
+ >}
+
}
+
+ >
+}
diff --git a/src/app/components/versions/index.ts b/src/app/components/versions/index.ts
index 1ffd2e69..b6213b9b 100644
--- a/src/app/components/versions/index.ts
+++ b/src/app/components/versions/index.ts
@@ -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'
diff --git a/src/app/hooks/useSearchParam.ts b/src/app/hooks/useSearchParam.ts
index c84fb1fb..c37e3cd1 100644
--- a/src/app/hooks/useSearchParam.ts
+++ b/src/app/hooks/useSearchParam.ts
@@ -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])
diff --git a/src/app/pages/Versions.tsx b/src/app/pages/Versions.tsx
index 24dc8d2f..1fa170f9 100644
--- a/src/app/pages/Versions.tsx
+++ b/src/app/pages/Versions.tsx
@@ -27,22 +27,20 @@ export function Versions({}: Props) {
return
{error && }
-
- {selectedId ? <>
-
-
-
-
-
-
- > : <>
-
`/versions/?id=${id}`} navigation={(
-
- )} />
- >}
-
+ {selectedId ? :
+ `/versions/?id=${id}`} navigation={(
+
+ )} />
+
}
}
diff --git a/src/app/services/DataFetcher.ts b/src/app/services/DataFetcher.ts
index 81cba052..742fb7d7 100644
--- a/src/app/services/DataFetcher.ts
+++ b/src/app/services/DataFetcher.ts
@@ -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 {
+export async function fetchBugfixes(version: string): Promise {
try {
const fixes = await cachedFetch(`${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(`${versionDiffUrl}/${version}`, { refresh: true })
+ return diff
+ } catch (e) {
+ throw new Error(`Error occured while fetching diff for version ${version}: ${message(e)}`)
}
}
diff --git a/src/locales/en.json b/src/locales/en.json
index ad68190c..e9ee53f6 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -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.",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 49bd80c8..c7474e32 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -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",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index f97d9562..580fbfb9 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -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)",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index a87ee36b..1889bb6b 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -186,7 +186,7 @@
"versions.released": "릴리즈됨",
"versions.resource_pack_format": "리소스 팩 형식",
"versions.search": "버전 검색",
- "versions.technical_changes": "기술적 변경",
+ "versions.changelog": "기술적 변경",
"world": "월드 설정",
"worldgen": "월드젠",
"worldgen/biome": "바이옴",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 0a2d65f1..407a5204 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -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": "Нет исправлений",
diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json
index 95c31de0..34d1080b 100644
--- a/src/locales/zh-cn.json
+++ b/src/locales/zh-cn.json
@@ -200,7 +200,7 @@
"versions.released": "发布于",
"versions.resource_pack_format": "资源包格式",
"versions.search": "搜索版本",
- "versions.technical_changes": "技术变更",
+ "versions.changelog": "技术变更",
"world": "世界设置",
"worldgen": "世界生成",
"worldgen/biome": "生物群系",
diff --git a/src/styles/global.css b/src/styles/global.css
index 32570ff3..4f2baa7f 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -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;