CodeDiff | CoUI
LogoCoUI

CodeDiff

라인 단위로 코드 변경 사항(추가/삭제/유지)을 표시하는 viewer 컴포넌트

CodeDiff#

코드 변경 사항을 라인 단위로 비교해서 보여주는 read-only viewer입니다. 추가된 라인은 success 톤으로, 삭제된 라인은 error 톤으로 강조하며 변경 없는 라인은 그대로 둡니다. PR 리뷰, 변경 이력 표시, 마이그레이션 가이드 등에 사용합니다.

이미지나 텍스트 두 가지를 좌우로 비교하는 split-view UI 가 필요하다면 Diff 컴포넌트를 사용하세요.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • PR / 커밋 변경 사항을 라인 단위로 보여줄 때
  • 마이그레이션 가이드에서 "이전 → 이후" 코드를 강조할 때
  • 변경 이력(audit log)을 코드 형태로 보여줄 때

대신 다른 컴포넌트를 사용하세요:

  • CodeSnippet: 단일 코드 블록을 보여줄 때 (변경 표시 없음)
  • Diff: 이미지/텍스트 split-view 비교가 필요할 때

빠른 오버라이드 (Chain)#

이미 만든 CodeDiff 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class CodeDiffChainExample extends StatelessWidget {
  const CodeDiffChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return const CodeDiff(
      lines: [
        CoreCodeDiffLine(
          text: "import 'package:coui_flutter/coui_flutter.dart';",
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 1,
        ),
        CoreCodeDiffLine(
          text: '',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 2,
        ),
        CoreCodeDiffLine(
          text: 'class MyWidget extends StatelessWidget {',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 3,
        ),
        CoreCodeDiffLine(
          text: '  Widget build(BuildContext context) {',
          type: CoreCodeDiffLineType.removed,
          lineNumber: 4,
        ),
        CoreCodeDiffLine(
          text: '  Widget build(BuildContext context) {',
          type: CoreCodeDiffLineType.added,
          lineNumber: 4,
        ),
        CoreCodeDiffLine(
          text: "    return Text('Hello');",
          type: CoreCodeDiffLineType.removed,
          lineNumber: 5,
        ),
        CoreCodeDiffLine(
          text: "    return Text('Hello').bodyMedium;",
          type: CoreCodeDiffLineType.added,
          lineNumber: 5,
        ),
        CoreCodeDiffLine(
          text: '  }',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 6,
        ),
        CoreCodeDiffLine(
          text: '}',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 7,
        ),
      ],
    ).withStyle(
      const CoreCodeDiffStyle(
        cornerRadius: CoreBorderRadius.all(CoreRadius.radius4),
        borderWidth: CoreStrokeWidth.stroke2,
        outlineColor: CoreColor.token(CoreColors.primary),
        addedColor: CoreColor.token(CoreColors.info),
        removedColor: CoreColor.token(CoreColors.warning),
        linePadding: CoreEdgeInsets.symmetric(
          horizontal: CoreSpace.space16,
          vertical: CoreSpace.space4,
        ),
      ),
    );
  }
}
class CodeDiffChainExample extends StatelessComponent {
  const CodeDiffChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return const CodeDiff(
      lines: [
        CoreCodeDiffLine(
          text: "import 'package:coui_web/coui_web.dart';",
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 1,
        ),
        CoreCodeDiffLine(
          text: '',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 2,
        ),
        CoreCodeDiffLine(
          text: 'class MyWidget extends StatelessComponent {',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 3,
        ),
        CoreCodeDiffLine(
          text: '  Component build(BuildContext context) {',
          type: CoreCodeDiffLineType.removed,
          lineNumber: 4,
        ),
        CoreCodeDiffLine(
          text: '  Component build(BuildContext context) {',
          type: CoreCodeDiffLineType.added,
          lineNumber: 4,
        ),
        CoreCodeDiffLine(
          text: "    return text('Hello');",
          type: CoreCodeDiffLineType.removed,
          lineNumber: 5,
        ),
        CoreCodeDiffLine(
          text: "    return Text('Hello').bodyMedium;",
          type: CoreCodeDiffLineType.added,
          lineNumber: 5,
        ),
        CoreCodeDiffLine(
          text: '  }',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 6,
        ),
        CoreCodeDiffLine(
          text: '}',
          type: CoreCodeDiffLineType.unchanged,
          lineNumber: 7,
        ),
      ],
    ).withStyle(
      const CoreCodeDiffStyle(
        cornerRadius: CoreBorderRadius.all(CoreRadius.radius4),
        borderWidth: CoreStrokeWidth.stroke2,
        outlineColor: CoreColor.token(CoreColors.primary),
        addedColor: CoreColor.token(CoreColors.info),
        removedColor: CoreColor.token(CoreColors.warning),
        linePadding: CoreEdgeInsets.symmetric(
          horizontal: CoreSpace.space16,
          vertical: CoreSpace.space4,
        ),
      ),
    );
  }
}

Props / Parameters#

속성타입기본값설명
lines List<CoreCodeDiffLine> 필수 표시할 라인 데이터
showLineNumbers bool true 라인 번호 거터 표시 여부
codeDiffStyle CoreCodeDiffStyle? null 컨테이너·행·텍스트 chrome 오버라이드 (아래 표 참고)

타이포그래피는 디자인 시스템의 시맨틱 토큰(bodySmall / labelSmall)을 그대로 따르며 sans 폰트로 렌더링됩니다. + / - / prefix 정렬은 고정폭 라인 번호 거터와 두 글자 prefix span 으로 보장됩니다 (별도의 monospace 폰트 지정 없음).

CoreCodeDiffLine — 라인 데이터#

위젯 파라미터가 아니라 lines 에 담아 넘기는 plain Dart 데이터 클래스입니다 (양 플랫폼 동일 모양).

필드타입설명
textString라인 텍스트 (필수)
type CoreCodeDiffLineType added / removed / unchanged (필수)
lineNumberint?거터에 표시할 라인 번호

CoreCodeDiffStyle 필드#

필드타입설명
cornerRadius CoreBorderRadius? Outer container border-radius. Falls back to [defaultCornerRadius].
borderWidth double? Outer container border stroke width (logical px). Falls back to [defaultBorderWidth].
prefixFontWeight int? Font weight applied to the + / - / prefix span. Falls back to [defaultPrefixFontWeight].
surfaceColor CoreColor? Outer container background colour. Falls back to [defaultSurfaceColor].
outlineColor CoreColor? Outer container border colour. Falls back to [defaultOutlineColor].
addedColor CoreColor? Background tint for added lines. The component applies the resolved [rowTintAlpha] when painting. Falls back to [defaultAddedColor].
removedColor CoreColor? Background tint for removed lines. The component applies the resolved [rowTintAlpha] when painting. Falls back to [defaultRemovedColor].
unchangedTextStyle CoreTextStyle? Text style for unchanged rows. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw unchangedTextColor field removed). Falls back to [defaultUnchangedTextStyle].
lineNumberColor CoreColor? Line-number gutter base text colour (alpha applied at paint time). Falls back to [defaultLineNumberColor].
rowTintAlpha double? Background-tint alpha applied to added / removed rows. Falls back to [defaultRowTintAlpha].
lineNumberAlpha double? Alpha applied to the line-number text colour. Falls back to [defaultLineNumberAlpha].
linePadding CoreEdgeInsets? Padding inside each diff row. Falls back to [defaultLinePadding].
lineNumberPadding CoreEdgeInsets? Padding around the line-number text. Falls back to [defaultLineNumberPadding].
lineNumberWidth double? Width of the line-number gutter column (logical px, pre-scaling). Falls back to [defaultLineNumberWidth].
bodyTypography CoreTextStyle? Typography role for the diff body text. Falls back to [defaultBodyTypography].
gutterTypography CoreTextStyle? Typography role for the line-number gutter text. Falls back to [defaultGutterTypography].
bodyTextStyle CoreTextStyle? Body text style override (the diff content). Deliberately has no default* — this is the overlay half of a pair whose other half already carries the default. The typography comes from [bodyTypography] / [defaultBodyTypography]; this slot is merged on top of whatever that resolved to. A constant would therefore outrank the role a caller selected through [bodyTypography] — set the role to something larger and the default overlay's own size would win it back. On Web it would also move the typography off its class: the resolver adds bodyTextStyle.toInlineCss only inside if (bodyTextStyle != null) , so a non-null default emits an inline overlay on every diff row and shadows the text-{role} class that carries the role today.
gutterTextStyle CoreTextStyle? Line-number gutter text style override. Deliberately has no default* — same pairing as [bodyTextStyle] , against [gutterTypography] / [defaultGutterTypography]. Both platforms consume it the same way (Flutter merges it over the resolved role, Web emits it inline only when present), so a constant would both outrank a caller-chosen gutter role and put inline typography on every gutter cell.

예제#

CodeDiff(
  lines: [
    CoreCodeDiffLine(
      text: 'class MyWidget extends StatelessWidget {',
      type: CoreCodeDiffLineType.unchanged,
      lineNumber: 1,
    ),
    CoreCodeDiffLine(
      text: "  return Text('Hello');",
      type: CoreCodeDiffLineType.removed,
      lineNumber: 2,
    ),
    CoreCodeDiffLine(
      text: "  return Text('Hello').bodyMedium;",
      type: CoreCodeDiffLineType.added,
      lineNumber: 2,
    ),
  ],
)

사용 가이드라인 (Usage Guidelines)#

✅ Do#

추가/삭제가 있는 실제 변경 비교에만 사용

CodeDiff(
  lines: [
    CoreCodeDiffLine(
      text: "  return Text('Hello');",
      type: CoreCodeDiffLineType.removed,
      lineNumber: 5,
    ),
    CoreCodeDiffLine(
      text: "  return Text('Hello').bodyMedium;",
      type: CoreCodeDiffLineType.added,
      lineNumber: 5,
    ),
  ],
)

+ / - / 공백 prefix 는 CSS 가 아니라 실제 텍스트 콘텐츠라, 변경 여부를 색상 없이도 전달합니다 — 이 구조는 실제 변경이 있을 때만 의미가 있습니다.


❌ Don't#

변경 없는 단일 코드 블록 표시에 사용하지 않기

// ❌ 변경 사항 없이 unchanged 라인만 나열
CodeDiff(
  lines: [
    CoreCodeDiffLine(
      text: 'final x = 1;',
      type: CoreCodeDiffLineType.unchanged,
      lineNumber: 1,
    ),
  ],
)

// ✅ 단일 코드 블록은 CodeSnippet
CodeSnippet(code: 'final x = 1;')

CodeDiff 는 코드/diff 시맨틱을 전혀 emit 하지 않는(read-only, <pre>/<code>/role="table" 없음) 뷰어입니다 — 변경이 없는 코드에 쓰면 접근성 이점 없이 무게만 늘어납니다.

접근성 (Accessibility)#

CodeDiff 는 read-only 뷰어이고, 접근성 측면에서도 아무 역할·이름·포커스를 내보내지 않습니다. 아래 내용은 양 플랫폼이 동일합니다.

역할 (Semantics)#

없습니다. Flutter 는 Container / Column / Row / Text 만으로 구성되어 Semantics 노드를 하나도 만들지 않고, Web 루트는 평범한 <div>, 각 행도 div / span 입니다. <pre> · <code> · <table> / role="table" · <ins> / <del> · aria-label 중 어느 것도 emit 하지 않습니다.

즉 리더는 이 블록을 일반 텍스트 흐름으로 지나가며, diff 를 하나의 단위로 찾거나 건너뛸 수 없습니다.

키보드#

키 처리가 없습니다. 양 플랫폼 모두 키 핸들러를 등록하지 않습니다.

포커스#

포커스 가능한 요소가 없습니다. FocusNode · tabindex · 포커스 링 모두 없습니다. Web 루트는 overflow-hidden 이고 행이 줄바꿈되므로(whitespace-pre-wrap), 포커스 가능한 래퍼가 필요한 스크롤 컨테이너도 생기지 않습니다.

스크린 리더#

각 행은 순서대로 평평한 텍스트로 읽힙니다 — 라인 번호(showLineNumbers 가 켜져 있고 line.lineNumber 가 있을 때만), 그다음 '+ ' / '- ' / ' ' prefix, 그다음 라인 텍스트.

이 prefix 는 CSS 가 아니라 실제 텍스트 콘텐츠입니다(양 플랫폼 동일). 덕분에 추가/삭제 구분이 색상에만 실려 있지는 않습니다. 다만 그 이상은 없어서, 나머지는 구분되지 않는 산문처럼 읽힙니다.

알려진 제약#

  • Web 에 코드 시맨틱이 없습니다 (<pre> / <code> 미사용). 리더가 코드 읽기 모드로 진입하지 않으므로 공백과 고정폭의 의미가 전달되지 않습니다.
  • diff 전용 시맨틱이 없습니다<ins> / <del> 도, "추가된 줄" 같은 행 단위 aria-label 도 없습니다. 유일한 신호는 리터럴 + / - 문자이고, 이 문자를 읽어줄지는 리더와 상세도(verbosity) 설정에 따라 달라집니다. 변경 없는 행의 공백 두 칸 prefix 는 대개 통째로 무시됩니다.
  • 라인 번호가 해당 라인과 연결되어 있지 않습니다 — 형제 텍스트 노드일 뿐이라 "5번째 줄의 내용" 이라는 관계가 전달되지 않습니다.
  • diff 전체에 이름도 역할도 없습니다. 랜드마크나 영역으로 찾아갈 수 없으므로, 이름 붙은 영역이 필요하면 감싸는 쪽에서 직접 역할과 이름을 부여해야 합니다.

전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.

  • CodeSnippet: 단일 코드 블록 표시
  • Diff: 이미지/텍스트 split-view 비교