Full output
Codex 원문 결과물
코드 비교 뷰어 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-24
06. 코드 비교 뷰어 구현 (Codex)
아래 5개 파일만 변경·추가합니다. package.json에는 테스트 실행에 필요한 최소 개발 의존성만 추가했습니다.
src/App.tsx
import { useState } from "react";
import "./index.css";
type ChangedFile = {
id: "profile" | "format-date" | "submit-button";
path: string;
before: string;
after: string;
};
const changedFiles: ChangedFile[] = [
{
id: "profile",
path: "src/components/Profile.tsx",
before: `export function Profile({ name }: { name: string }) {
return <div>{name}</div>;
}`,
after: `type ProfileProps = {
name: string;
role: string;
};
export function Profile({ name, role }: ProfileProps) {
return <p>{name} · {role}</p>;
}`,
},
{
id: "format-date",
path: "src/lib/formatDate.ts",
before: `export function formatDate(value: string) {
return new Date(value).toLocaleDateString();
}`,
after: `export function formatDate(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
}`,
},
{
id: "submit-button",
path: "src/components/SubmitButton.tsx",
before: `export function SubmitButton() {
return <button>저장</button>;
}`,
after: `type SubmitButtonProps = {
disabled?: boolean;
};
export function SubmitButton({ disabled = false }: SubmitButtonProps) {
return <button disabled={disabled}>저장</button>;
}`,
},
];
type Version = "before" | "after";
type CopyStatus = "idle" | "success" | "error";
export default function App() {
const [selectedFileId, setSelectedFileId] = useState<ChangedFile["id"]>(
"profile",
);
const [version, setVersion] = useState<Version>("before");
const [copyStatus, setCopyStatus] = useState<CopyStatus>("idle");
const selectedFile = changedFiles.find((file) => file.id === selectedFileId)!;
const currentCode = selectedFile[version];
const versionLabel = version === "before" ? "변경 전" : "변경 후";
async function copyCurrentCode() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard API is unavailable");
}
await navigator.clipboard.writeText(currentCode);
setCopyStatus("success");
} catch {
setCopyStatus("error");
}
}
function selectFile(id: ChangedFile["id"]) {
setSelectedFileId(id);
setCopyStatus("idle");
}
function selectVersion(nextVersion: Version) {
setVersion(nextVersion);
setCopyStatus("idle");
}
return (
<main className="page">
<header className="intro">
<p className="eyebrow">Change review</p>
<h1>코드 비교 뷰어</h1>
<p>세 파일의 변경 전후 코드를 선택해 확인하고, 현재 코드를 복사할 수 있습니다.</p>
</header>
<div className="viewer">
<nav className="filePanel" aria-label="변경 파일">
<p className="sectionLabel">변경 파일</p>
<div className="fileButtons" role="group" aria-label="변경 파일">
{changedFiles.map((file) => (
<button
key={file.id}
type="button"
className="fileButton"
aria-pressed={file.id === selectedFileId}
onClick={() => selectFile(file.id)}
>
{file.path}
</button>
))}
</div>
</nav>
<section className="codePanel" aria-labelledby="selected-file-path">
<div className="toolbar">
<div className="versionButtons" role="group" aria-label="표시할 버전">
{(["before", "after"] as const).map((item) => (
<button
key={item}
type="button"
className="versionButton"
aria-pressed={version === item}
onClick={() => selectVersion(item)}
>
{item === "before" ? "변경 전" : "변경 후"}
</button>
))}
</div>
<button type="button" className="copyButton" onClick={copyCurrentCode}>
{copyStatus === "success" ? "복사됨" : "코드 복사"}
</button>
</div>
<p className="copyStatus" role="status" aria-live="polite">
{copyStatus === "success" && "복사됨"}
{copyStatus === "error" && "복사하지 못했습니다."}
</p>
<div className="codeHeading">
<h2 id="selected-file-path">{selectedFile.path}</h2>
<span>{versionLabel}</span>
</div>
<pre aria-label={`${selectedFile.path} ${versionLabel} 코드`}>
<code>{currentCode}</code>
</pre>
</section>
</div>
</main>
);
}
src/index.css
:root {
color: #18222a;
background: #f5f7f6;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button { font: inherit; }
button:focus-visible {
outline: 3px solid #246bce;
outline-offset: 2px;
}
.page {
width: min(100% - 32px, 1080px);
margin: 0 auto;
padding: 56px 0 80px;
}
.intro { max-width: 680px; margin-bottom: 32px; }
.eyebrow { margin: 0 0 8px; color: #426f69; font-size: 0.78rem; font-weight: 750; letter-spacing: 0.1em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 5vw, 3rem); letter-spacing: -0.05em; }
.intro > p:last-child { margin: 14px 0 0; color: #52636b; line-height: 1.65; }
.viewer {
display: grid;
grid-template-columns: minmax(0, 190px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.filePanel, .codePanel { min-width: 0; }
.sectionLabel { margin: 0 0 10px; color: #52636b; font-size: 0.85rem; font-weight: 700; }
.fileButtons, .versionButtons { display: flex; flex-wrap: wrap; gap: 8px; }
.fileButtons { flex-direction: column; }
.fileButton, .versionButton, .copyButton {
border: 1px solid #c8d1cf;
border-radius: 8px;
background: #fff;
color: #26343a;
cursor: pointer;
font-weight: 650;
}
.fileButton { width: 100%; padding: 10px 12px; overflow-wrap: anywhere; text-align: left; }
.versionButton, .copyButton { padding: 9px 12px; }
.fileButton[aria-pressed="true"], .versionButton[aria-pressed="true"] { border-color: #246bce; background: #eaf2ff; color: #134d9b; }
.copyButton { margin-left: auto; border-color: #1f605b; background: #1f605b; color: #fff; }
.copyButton:hover { background: #164b47; }
.codePanel { border: 1px solid #d6dddb; border-radius: 12px; background: #fff; overflow: hidden; }
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 14px 16px; border-bottom: 1px solid #d6dddb; }
.copyStatus { min-height: 1.25rem; margin: 0; padding: 8px 16px 0; color: #1f605b; font-size: 0.875rem; font-weight: 650; }
.codeHeading { display: flex; gap: 12px; justify-content: space-between; align-items: center; padding: 12px 16px; }
.codeHeading h2 { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 0.95rem; }
.codeHeading span { flex: none; color: #52636b; font-size: 0.82rem; }
pre {
width: 100%;
margin: 0;
padding: 18px;
overflow-x: auto;
border-top: 1px solid #d6dddb;
background: #172128;
color: #edf4f2;
font: 0.875rem/1.65 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre;
}
@media (max-width: 767px) {
.page { width: min(100% - 24px, 1080px); padding-top: 32px; }
.viewer { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.fileButtons { flex-direction: row; }
.fileButton { width: auto; flex: 1 1 150px; }
.toolbar { padding: 12px; }
.copyButton { margin-left: 0; }
.codeHeading { padding: 12px; }
pre { padding: 14px; }
}
src/App.test.tsx
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});
describe("App", () => {
it("처음에는 첫 파일의 변경 전 코드를 표시한다", () => {
render(<App />);
expect(screen.getByRole("heading", { name: "src/components/Profile.tsx" })).toBeTruthy();
expect(screen.getByLabelText("src/components/Profile.tsx 변경 전 코드").textContent).toBe(
"export function Profile({ name }: { name: string }) {\n return <div>{name}</div>;\n}",
);
expect(screen.getByRole("button", { name: "변경 전" }).getAttribute("aria-pressed")).toBe("true");
});
it("파일과 버전을 바꾸면 경로, 코드, 선택 상태를 갱신한다", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "src/lib/formatDate.ts" }));
fireEvent.click(screen.getByRole("button", { name: "변경 후" }));
expect(screen.getByRole("heading", { name: "src/lib/formatDate.ts" })).toBeTruthy();
expect(screen.getByLabelText("src/lib/formatDate.ts 변경 후 코드").textContent).toContain(
'new Intl.DateTimeFormat("ko-KR"',
);
expect(screen.getByRole("button", { name: "src/lib/formatDate.ts" }).getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("button", { name: "변경 후" }).getAttribute("aria-pressed")).toBe("true");
});
it("현재 보이는 코드를 복사하고 성공 상태를 알린다", async () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "변경 후" }));
fireEvent.click(screen.getByRole("button", { name: "코드 복사" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith(
`type ProfileProps = {
name: string;
role: string;
};
export function Profile({ name, role }: ProfileProps) {
return <p>{name} · {role}</p>;
}`,
));
expect(screen.getByRole("status").textContent).toBe("복사됨");
});
});
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
},
});
package.json
{
"name": "samebrief",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 127.0.0.1",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"gray-matter": "^4.0.3",
"next": "^16.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.0.0",
"remark-gfm": "^4.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@playwright/test": "^1.49.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"eslint": "^9.17.0",
"eslint-config-next": "^16.0.1",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vitest": "^3.0.0"
}
}
확인 방법: 파일·버전·복사 버튼은 모두 네이티브 <button>이므로 Tab으로 이동한 뒤 Enter 또는 Space로 동작합니다. 성공·실패 복사 상태는 aria-live="polite"인 status 영역에서 읽습니다. 375px에서는 파일/버전 버튼이 줄바꿈되고, pre만 overflow-x: auto이므로 페이지 자체가 가로로 넘치지 않습니다.