비교 요약으로 돌아가기

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에서는 파일/버전 버튼이 줄바꿈되고, preoverflow-x: auto이므로 페이지 자체가 가로로 넘치지 않습니다.