import { type CodeDiff, type Diff, type Differs, DiffType, type Icons, type RawColors, type SemanticColors, } from "../../types"; import type { ChannelContext } from "../channel"; import { fetchGitChanged, gitChanged } from "../git"; import { type Progress, wrapPromise } from "../progress"; import { diffAnyway, prevFiles } from "../shared"; import { join, parseJsonl } from "../utils"; interface DiffOpts { getSource: (key: string, val: TNew) => string; getLabel?: (key: string, val: TNew) => string; getOldSource?: (key: string, val: TOld) => string; getOldLabel?: (key: string, val: TOld) => string; isRename: (newKey: string, oldVal: TOld, newVal: TNew) => boolean; isChanged?: (oldVal: TOld, newVal: TNew) => boolean; } function diffEntries( oldEntries: Record, newEntries: Record, opts: DiffOpts, ): Map { const changes = new Map(); const renamed = new Set(); for (const [name, newVal] of Object.entries(newEntries)) { const oldVal = oldEntries[name]; if (oldVal === undefined) { const oldName = Object.entries(oldEntries).find( ([key, val]) => !(key in newEntries) && opts.isRename(name, val, newVal), )?.[0]; if (!oldName) { changes.set(name, { type: DiffType.Added, source: opts.getSource(name, newVal), label: opts.getLabel?.(name, newVal), }); } else { renamed.add(oldName); changes.set(name, { type: DiffType.Renamed, oldName, source: opts.getSource(name, newVal) }); } } else if (opts.isChanged?.(oldVal, newVal)) { changes.set(name, { type: DiffType.Changed, source: opts.getSource(name, newVal), label: opts.getLabel?.(name, newVal), oldSource: opts.getOldSource?.(name, oldVal) ?? "", oldLabel: opts.getOldLabel?.(name, oldVal), }); } } for (const [name, oldVal] of Object.entries(oldEntries)) { if (name in newEntries || renamed.has(name)) continue; changes.set(name, { type: DiffType.Removed, source: opts.getOldSource?.(name, oldVal) ?? "", label: opts.getOldLabel?.(name, oldVal), }); } return changes; } function diffEntriesCode( oldEntries: Record, newEntries: Record, ): Map { const changes = new Map(); const renamed = new Set(); for (const [name, newVal] of Object.entries(newEntries)) { if (!(name in oldEntries)) { const oldName = Object.entries(oldEntries).find( ([key, val]) => !(key in newEntries) && val.compSize === newVal.compSize, )?.[0]; if (!oldName) { changes.set(name, { type: DiffType.Added, size: newVal.size }); } else { renamed.add(oldName); changes.set(name, { type: DiffType.Renamed, oldName, size: newVal.size }); } } } for (const [name, oldVal] of Object.entries(oldEntries)) { if (!(name in newEntries || renamed.has(name))) { changes.set(name, { type: DiffType.Removed, size: oldVal.size }); } } return changes; } async function diffRaw(channel: ChannelContext) { if (!prevFiles.has("raw.json")) throw new Error("Missing prevFile: raw.json"); const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors; const newRaw: RawColors = await Bun.file(join(channel.canvasDir, "raw.json")).json(); return diffEntries(oldRaw, newRaw, { getSource: (_, val) => val, isRename: (newKey, oldVal, newVal) => !(newKey in newRaw) && oldVal === newVal, isChanged: (oldVal, newVal) => oldVal.toLowerCase() !== newVal.toLowerCase(), }); } function getSemanticLabel(semantic: Record) { const light = semantic.light; const dark = semantic.darker ?? semantic.dark; if (dark && light) return `${light[0]}, ${dark[0]}`; return Object.entries(semantic) .map(([name, [color]]) => `${name} ${color}`) .join(", "); } async function diffSemantic(channel: ChannelContext) { if (!prevFiles.has("semantic.json")) throw new Error("Missing prevFile: semantic.json"); const oldSemantic = JSON.parse(new TextDecoder().decode(prevFiles.get("semantic.json"))) as SemanticColors; const newSemantic: SemanticColors = await Bun.file(join(channel.canvasDir, "semantic.json")).json(); const changes = diffEntries(oldSemantic, newSemantic, { getSource: (_, val) => Object.values(val) .map(([color]) => color) .join(","), getLabel: (_, val) => getSemanticLabel(val), isRename: (newKey, oldVal, newVal) => { const themes = Object.keys(newVal).length; return ( !(newKey in newSemantic) && Object.keys(oldVal).length === themes && Object.entries(oldVal).every(([skey, sval]) => sval[0] === newVal[skey]?.[0]) ); }, }); for (const [name, semantic] of Object.entries(newSemantic)) { if (oldSemantic[name]) { for (const [theme, [color]] of Object.entries(semantic)) { if (!oldSemantic[name][theme]) { changes.set(`${name}.${theme}`, { type: DiffType.Added, source: color }); } else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase()) { changes.set(`${name}.${theme}`, { type: DiffType.Changed, source: color, oldSource: oldSemantic[name][theme][0], }); } } } } for (const [name, semantic] of Object.entries(oldSemantic)) { if (name in newSemantic) { for (const [theme, [source]] of Object.entries(semantic)) { if (!newSemantic[name][theme]) { changes.set(`${name}.${theme}`, { type: DiffType.Removed, source }); } } } } return changes; } async function diffIcons(channel: ChannelContext) { if (!prevFiles.has("icons.json")) throw new Error("Missing prevFile: icons.json"); const oldIcons = JSON.parse(new TextDecoder().decode(prevFiles.get("icons.json"))) as Icons; const newIcons: Icons = await Bun.file(join(channel.canvasDir, "icons.json")).json(); const iconDir = { old: join(channel.canvasDir, "oldicons"), new: join(channel.canvasDir, "icons"), }; return diffEntries(oldIcons, newIcons, { getSource: (_, val) => join(iconDir.new, val.file), getLabel: (_, val) => val.hash.slice(0, 8), getOldSource: (_, val) => join(iconDir.old, val.file), getOldLabel: (_, val) => val.hash.slice(0, 8), isRename: (newKey, oldVal, newVal) => !(newKey in newIcons) && oldVal.hash === newVal.hash, isChanged: (oldVal, newVal) => oldVal.hash !== newVal.hash, }); } function parseSource(text: string) { const source = parseJsonl(text) as { file: string; size: number }[]; return Object.fromEntries( source.map(({ file, size }) => [file, { size, compSize: size - JSON.stringify(file).length }]), ) as Record; } async function diffCode(channel: ChannelContext) { if (!prevFiles.has("source.jsonl")) throw new Error("Missing prevFile: source.jsonl"); const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); const newCode = parseSource(await Bun.file(join(channel.dataDir, "source.jsonl")).text()); return diffEntriesCode(oldCode, newCode); } export default async function diffs(channel: ChannelContext, progress: Progress) { await fetchGitChanged(); const prefix = `${channel.channel}/`; const changed = (name: string) => gitChanged.has(prefix + name); const hasBaseline = (name: string) => prevFiles.has(name as typeof prevFiles extends Map ? K : never); if (!(changed("version.txt") || diffAnyway)) { progress.update("diff", null); progress.update("diff_raw", null); progress.update("diff_semantic", null); progress.update("diff_icons", null); progress.update("diff_code", null); return; } const differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() }; await Promise.all([ changed("raw.json") && hasBaseline("raw.json") ? wrapPromise( diffRaw(channel).then((x) => (differs.raw = x)), progress, "diff_raw", ) : progress.update("diff_raw", null), changed("semantic.json") && hasBaseline("semantic.json") ? wrapPromise( diffSemantic(channel).then((x) => (differs.semantic = x)), progress, "diff_semantic", ) : progress.update("diff_semantic", null), changed("icons.json") && hasBaseline("icons.json") ? wrapPromise( diffIcons(channel).then((x) => (differs.icons = x)), progress, "diff_icons", ) : progress.update("diff_icons", null), changed("source.jsonl") && hasBaseline("source.jsonl") ? wrapPromise( diffCode(channel).then((x) => (differs.code = x)), progress, "diff_code", ) : progress.update("diff_code", null), ]); progress.update("diff", true); return differs; }