본문으로 건너뛰기
블록

여러 파일 코드 뷰어

파일 탭으로 여러 소스를 보여 주고, 긴 파일은 접어 둬요.

  • #arc
  • #block
  • #code
  • #files
  • #tabs
  • #source
  • #viewer

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/source-files.json

source-files.module.csscss
.files { display: flex; flex-direction: column; gap: var(--space-3); }
.files pre { margin: 0; }
source-files.tsxtsx
"use client";

import { useState } from "react";
import { CodeBlock } from "../../code-block/code-block";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../tabs/tabs";
import styles from "./source-files.module.css";

export interface SourceFile {
  /** The file's name, shown on its tab. Must be unique within the set. */
  name: string;
  code: string;
  /** Used for the header label and the lightweight highlighting. Guessed from the extension when left out. */
  language?: string;
}

export interface SourceFilesProps {
  files: SourceFile[];
  /** The accessible name of the tab row, e.g. "파일". */
  label?: string;
  /** Collapse each file to this many lines, with a toggle that opens the rest in place. */
  maxLines?: number;
  className?: string;
}

const EXTENSIONS: Record<string, string> = {
  ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", css: "css",
  json: "json", md: "markdown", html: "html", sh: "bash", bash: "bash",
};

function languageOf(file: SourceFile): string | undefined {
  if (file.language) return file.language;
  const ext = file.name.split(".").pop()?.toLowerCase();
  return ext ? EXTENSIONS[ext] : undefined;
}

/**
 * One component spread over several files, as tabs over a code block each. CodeBlock shows one file at a time and
 * copies one file at a time, which is what a reader wants: they are going to paste these into separate files anyway.
 * With a single file it renders the block alone — a row of one tab says nothing.
 */
export function SourceFiles({ files, label = "파일", maxLines = 24, className }: SourceFilesProps) {
  const [active, setActive] = useState(files[0]?.name ?? "");
  if (files.length === 0) return null;
  if (files.length === 1) {
    const only = files[0];
    return (
      <div className={[styles.files, className].filter(Boolean).join(" ")}>
        <CodeBlock code={only.code} filename={only.name} language={languageOf(only)} maxLines={maxLines} />
      </div>
    );
  }
  return (
    <Tabs value={active} onValueChange={setActive} className={[styles.files, className].filter(Boolean).join(" ")}>
      <TabsList aria-label={label}>
        {files.map(file => <TabsTrigger key={file.name} value={file.name}>{file.name}</TabsTrigger>)}
      </TabsList>
      {files.map(file => (
        <TabsContent key={file.name} value={file.name}>
          <CodeBlock code={file.code} filename={file.name} language={languageOf(file)} maxLines={maxLines} />
        </TabsContent>
      ))}
    </Tabs>
  );
}

SourceFiles.displayName = "SourceFiles";

export default SourceFiles;

사용된 항목