merge develop into release
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-07 10:18:14 +09:00
+106 -18
View File
@@ -1,5 +1,5 @@
import { Copy, FileText, Link2, RotateCcw } from 'lucide-react'; import { Copy, FileText, Link2, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
import { useToast } from '../../components/toast'; import { useToast } from '../../components/toast';
import { PageTitle } from '../../layout/AppLayout'; import { PageTitle } from '../../layout/AppLayout';
@@ -58,6 +58,13 @@ type DiffFile = {
removed: number; removed: number;
}; };
type DiffTreeNode = {
name: string;
path: string;
children: DiffTreeNode[];
file?: DiffFile;
};
function repoFullName(repo: GiteaRepo) { function repoFullName(repo: GiteaRepo) {
return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name); return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name);
} }
@@ -222,8 +229,96 @@ function diffLineClass(line: string) {
return 'text-slate-300'; return 'text-slate-300';
} }
function buildDiffTree(files: DiffFile[]) {
const root: DiffTreeNode = { name: '', path: '', children: [] };
files.forEach((file) => {
const parts = file.path.split('/').filter(Boolean);
if (parts.length === 0) parts.push(file.path);
let current = root;
parts.forEach((part, index) => {
const path = parts.slice(0, index + 1).join('/');
let next = current.children.find((child) => child.name === part);
if (!next) {
next = { name: part, path, children: [] };
current.children.push(next);
}
if (index === parts.length - 1) next.file = file;
current = next;
});
});
const sortNodes = (nodes: DiffTreeNode[]) => {
nodes.sort((a, b) => {
if (Boolean(a.file) !== Boolean(b.file)) return a.file ? 1 : -1;
return a.name.localeCompare(b.name);
});
nodes.forEach((node) => sortNodes(node.children));
};
sortNodes(root.children);
return root.children;
}
function DiffTree({
nodes,
depth = 0,
onSelect
}: {
nodes: DiffTreeNode[];
depth?: number;
onSelect: (file: DiffFile) => void;
}) {
return (
<div className={depth === 0 ? 'space-y-0.5' : 'space-y-0.5'}>
{nodes.map((node) => (
<div key={node.path || node.name}>
{node.file ? (
<button
type="button"
className="flex w-full items-start justify-between gap-2 rounded py-1.5 pr-2 text-left text-slate-300 transition-colors hover:bg-slate-800/80 hover:text-slate-50"
style={{ paddingLeft: `${8 + depth * 14}px` }}
onClick={() => onSelect(node.file as DiffFile)}
title={node.file.path}
>
<span className="min-w-0 flex-1 truncate font-mono text-[12px]">{node.name}</span>
<span className="flex shrink-0 items-center gap-1 font-mono text-[11px]">
<span className="text-emerald-300">+{node.file.added}</span>
<span className="text-rose-300">-{node.file.removed}</span>
</span>
</button>
) : (
<div
className="truncate py-1 pr-2 font-mono text-[12px] font-semibold text-slate-400"
style={{ paddingLeft: `${8 + depth * 14}px` }}
title={node.path}
>
{node.name}
</div>
)}
{node.children.length ? <DiffTree nodes={node.children} depth={depth + 1} onSelect={onSelect} /> : null}
</div>
))}
</div>
);
}
function DiffViewer({ diff }: { diff: string }) { function DiffViewer({ diff }: { diff: string }) {
const files = parseDiffFiles(diff); const files = parseDiffFiles(diff);
const tree = useMemo(() => buildDiffTree(files), [files]);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const scrollToFile = (file: DiffFile) => {
const scroller = scrollerRef.current;
const section = sectionRefs.current[file.id];
if (!scroller || !section) return;
scroller.scrollTo({
top: section.offsetTop - scroller.offsetTop - 12,
behavior: 'smooth'
});
};
return ( return (
<div className="grid max-h-[56vh] overflow-hidden rounded-md border border-border bg-[#080b10] text-xs leading-5 lg:grid-cols-[280px_minmax(0,1fr)]"> <div className="grid max-h-[56vh] overflow-hidden rounded-md border border-border bg-[#080b10] text-xs leading-5 lg:grid-cols-[280px_minmax(0,1fr)]">
@@ -233,27 +328,20 @@ function DiffViewer({ diff }: { diff: string }) {
<p className="mt-0.5 text-[11px] text-slate-400">총 {files.length}개</p> <p className="mt-0.5 text-[11px] text-slate-400">총 {files.length}개</p>
</div> </div>
<div className="max-h-44 overflow-y-auto p-2 lg:max-h-[calc(56vh-50px)]"> <div className="max-h-44 overflow-y-auto p-2 lg:max-h-[calc(56vh-50px)]">
{files.map((file) => ( <DiffTree nodes={tree} onSelect={scrollToFile} />
<button
key={`tree-${file.id}`}
type="button"
className="flex w-full items-start justify-between gap-2 rounded px-2 py-1.5 text-left text-slate-300 transition-colors hover:bg-slate-800/80 hover:text-slate-50"
onClick={() => document.getElementById(`diff-file-${file.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
title={file.path}
>
<span className="min-w-0 flex-1 truncate font-mono text-[12px]">{file.path}</span>
<span className="flex shrink-0 items-center gap-1 font-mono text-[11px]">
<span className="text-emerald-300">+{file.added}</span>
<span className="text-rose-300">-{file.removed}</span>
</span>
</button>
))}
</div> </div>
</aside> </aside>
<div className="min-w-0 overflow-auto p-3 font-mono"> <div ref={scrollerRef} className="min-w-0 overflow-auto p-3 font-mono">
<div className="space-y-4"> <div className="space-y-4">
{files.map((file) => ( {files.map((file) => (
<section id={`diff-file-${file.id}`} key={file.id} className="min-w-max scroll-mt-3 overflow-hidden rounded-md border border-slate-700/80 bg-[#0b0f14]"> <section
id={`diff-file-${file.id}`}
key={file.id}
ref={(element) => {
sectionRefs.current[file.id] = element;
}}
className="min-w-max scroll-mt-3 overflow-hidden rounded-md border border-slate-700/80 bg-[#0b0f14]"
>
<div className="sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-slate-700/80 bg-[#111827] px-4 py-2"> <div className="sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-slate-700/80 bg-[#111827] px-4 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-[13px] font-semibold text-slate-100">{file.path}</p> <p className="truncate text-[13px] font-semibold text-slate-100">{file.path}</p>