CodeDiff
라인 단위로 코드 변경 사항(추가/삭제/유지)을 표시하는 viewer 컴포넌트
목차
코드 변경 사항을 라인 단위로 비교해서 보여주는 read-only viewer입니다. 추가된 라인은 success 톤으로, 삭제된 라인은 error 톤으로 강조하며 변경 없는 라인은 그대로 둡니다. PR 리뷰, 변경 이력 표시, 마이그레이션 가이드 등에 사용합니다.
이미지나 텍스트 두 가지를 좌우로 비교하는 split-view UI 가 필요하다면
Diff컴포넌트를 사용하세요.
Live Preview#
코드 보기
class CodeDiffDefaultExample extends StatelessComponent {
const CodeDiffDefaultExample({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,
),
],
);
}
}
class CodeDiffDefaultExample extends StatelessWidget {
const CodeDiffDefaultExample({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,
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- PR / 커밋 변경 사항을 라인 단위로 보여줄 때
- 마이그레이션 가이드에서 "이전 → 이후" 코드를 강조할 때
- 변경 이력(audit log)을 코드 형태로 보여줄 때
대신 다른 컴포넌트를 사용하세요:
- CodeSnippet: 단일 코드 블록을 보여줄 때 (변경 표시 없음)
- Diff: 코드 행 데이터의 추가/삭제 대신 두 콘텐츠 층을 분할선으로 비교할 때
빠른 오버라이드 (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),
addedBackgroundColor: CoreColor.token(CoreColors.infoContainer),
addedForegroundColor: CoreColor.token(CoreColors.onInfoContainer),
removedBackgroundColor: CoreColor.token(CoreColors.warningContainer),
removedForegroundColor: CoreColor.token(CoreColors.onWarningContainer),
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),
addedBackgroundColor: CoreColor.token(CoreColors.infoContainer),
addedForegroundColor: CoreColor.token(CoreColors.onInfoContainer),
removedBackgroundColor: CoreColor.token(CoreColors.warningContainer),
removedForegroundColor: CoreColor.token(CoreColors.onWarningContainer),
linePadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space4,
),
),
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
lines |
List<CoreCodeDiffLine> |
필수 | 표시할 라인 데이터 |
showLineNumbers |
bool |
true |
라인 번호 거터 표시 여부 |
onLineTap |
void Function(CoreCodeDiffLine)? |
null |
행 활성화 시 원본 라인 데이터를 전달 |
codeDiffStyle |
CoreCodeDiffStyle? |
null |
컨테이너·행·텍스트 chrome 오버라이드 (아래 표 참고) |
본문은 codeMd, 라인 번호 거터는 codeSm 코드 역할로 렌더링되어 고정폭입니다.
+ / - / prefix 정렬은 고정폭 라인 번호 거터와 두 글자 prefix span 으로
보장됩니다. 추가·삭제 행은 불투명 컨테이너 토큰 밴드에 앉고, 본문 글자는 모든
행에서 onSurface 입니다.
CoreCodeDiffLine — 라인 데이터#
위젯 파라미터가 아니라 lines 에 담아 넘기는 plain Dart 데이터 클래스입니다
(양 플랫폼 동일 모양).
| 필드 | 타입 | 설명 |
|---|---|---|
text | String | 라인 텍스트 (필수) |
type |
CoreCodeDiffLineType |
added / removed / unchanged (필수) |
lineNumber | int? | 거터에 표시할 라인 번호 |
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] (transparent — only visible once [borderWidth] is raised). |
addedBackgroundColor |
CoreColor? |
Band face of an added row — opaque, painted across the whole row. Falls back to [defaultAddedBackgroundColor]. |
addedForegroundColor |
CoreColor? |
Ink of an added row's
+
prefix and gutter number — the colour drawn on [addedBackgroundColor]. Falls back to [defaultAddedForegroundColor].
|
removedBackgroundColor |
CoreColor? |
Band face of a removed row — opaque, painted across the whole row. Falls back to [defaultRemovedBackgroundColor]. |
removedForegroundColor |
CoreColor? |
Ink of a removed row's
-
prefix and gutter number — the colour drawn on [removedBackgroundColor]. Falls back to [defaultRemovedForegroundColor].
|
addedColor |
CoreColor? |
Deprecated (since 0.162) — Use addedBackgroundColor (the band) and addedForegroundColor (the prefix and gutter number) instead — the band is now an opaque container token, so one colour can no longer be both the tint and the ink. Retired: read by no platform. State [addedBackgroundColor] (the band) and [addedForegroundColor] (the prefix and gutter number) instead. |
removedColor |
CoreColor? |
Deprecated (since 0.162) — Use removedBackgroundColor (the band) and removedForegroundColor (the prefix and gutter number) instead — the band is now an opaque container token, so one colour can no longer be both the tint and the ink. Retired: read by no platform. State [removedBackgroundColor] (the band) and [removedForegroundColor] (the prefix and gutter number) instead. |
unchangedTextStyle |
CoreTextStyle? |
Text style carrying the diff body ink. Its colour is the ink of the diff text on every row — added, removed and unchanged — and of an unchanged row's blank prefix; a band never recolours code (sb8 — raw
unchangedTextColor
field removed). Falls back to [defaultUnchangedTextStyle].
|
lineNumberColor |
CoreColor? |
Line-number gutter text colour on a plain (unchanged) row — opaque. A band row's number reads [addedForegroundColor] / [removedForegroundColor] instead. Falls back to [defaultLineNumberColor]. |
rowTintAlpha |
double? |
Deprecated (since 0.162) — Use addedBackgroundColor / removedBackgroundColor instead — a band is an opaque container token, not success / error at a tint alpha. Retired: read by no platform. A band is [addedBackgroundColor] / [removedBackgroundColor], an opaque container token, not success / error at a tint alpha. |
lineNumberAlpha |
double? |
Deprecated (since 0.162) — Unused — the gutter number is an opaque token: lineNumberColor on a plain row, addedForegroundColor / removedForegroundColor on a band. Hierarchy is the token, not an alpha. Retired: read by no platform. The gutter number is an opaque token ([lineNumberColor] / [addedForegroundColor] / [removedForegroundColor]); hierarchy is the token chosen, not an alpha. |
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? |
Total width of the line-number gutter cell, padding included (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,
),
],
)
동작 스펙 (Behavior)#
-
lines를 받은 순서대로 렌더합니다. added는+, removed는-, unchanged는 공백 prefix이며 두 파일의 diff를 자동 계산하지 않습니다. -
줄 번호는
showLineNumbers가 참이고 각 행의lineNumber가 있을 때만 표시합니다. 번호를 새로 생성하거나 재정렬하지 않습니다. -
onLineTap이 있으면 행을Clickable로 감싸 탭 · Enter · Space에서 그 행을 전달하고, 없으면 표시 전용입니다. 선택·편집·비활성·복사 기능은 없습니다.
사용 가이드라인 (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;', mode: 'dart')
CodeDiff 는 코드/diff 시맨틱을 전혀 emit 하지 않는(read-only, <pre>/<code>/role="table" 없음) 뷰어입니다 — 변경이 없는 코드에 쓰면 접근성 이점 없이 무게만 늘어납니다.
접근성 (Accessibility)#
CodeDiff 는 편집 없는 뷰어입니다. onLineTap 이 없으면 표시용이고, 제공하면 행별 Clickable
활성화·포커스 표면이 추가됩니다.
역할#
루트에 diff 전용 역할은 없습니다. 표시용 행은 일반 텍스트이며 onLineTap 행은 Flutter button 시맨틱 / Web role="button"
을 합성된 Clickable 에서 받습니다. <pre> · <code> · <table>
· <ins> / <del> 의미는 자동으로 붙이지 않습니다.
즉 리더는 이 블록을 일반 텍스트 흐름으로 지나가며, diff 를 하나의 단위로 찾거나 건너뛸 수 없습니다.
키보드#
표시용 행에는 키 처리가 없습니다. onLineTap 을 제공한 행은 Tab으로 도달하고 Enter / Space로 해당 라인을 활성화합니다.
포커스 관리#
표시용 경로에는 포커스 가능한 요소가 없습니다. 활성 행은 Clickable의 포커스·링을 사용하며 루트에서 포커스를 가두거나 이동하지 않습니다.
Web 루트는 overflow-hidden 이고 행이 줄바꿈되므로(whitespace-pre-wrap),
포커스 가능한 래퍼가 필요한 스크롤 컨테이너도 생기지 않습니다.
스크린 리더#
각 행은 순서대로 평평한 텍스트로 읽힙니다 — 라인 번호(showLineNumbers 가 켜져
있고 line.lineNumber 가 있을 때만), 그다음 '+ ' / '- '
/ ' ' prefix,
그다음 라인 텍스트.
이 prefix 는 CSS 가 아니라 실제 텍스트 콘텐츠입니다(양 플랫폼 동일). 덕분에 추가/삭제 구분이 색상에만 실려 있지는 않습니다. 다만 그 이상은 없어서, 나머지는 구분되지 않는 산문처럼 읽힙니다.
알려진 제약#
-
Web 에 코드 시맨틱이 없습니다 (
<pre>/<code>미사용). 리더가 코드 읽기 모드로 진입하지 않으므로 공백과 고정폭의 의미가 전달되지 않습니다. -
diff 전용 시맨틱이 없습니다 —
<ins>/<del>도, "추가된 줄" 같은 행 단위aria-label도 없습니다. 유일한 신호는 리터럴+/-문자이고, 이 문자를 읽어줄지는 리더와 상세도(verbosity) 설정에 따라 달라집니다. 변경 없는 행의 공백 두 칸 prefix 는 대개 통째로 무시됩니다. - 라인 번호가 해당 라인과 연결되어 있지 않습니다 — 형제 텍스트 노드일 뿐이라 "5번째 줄의 내용" 이라는 관계가 전달되지 않습니다.
- diff 전체에 이름도 역할도 없습니다. 랜드마크나 영역으로 찾아갈 수 없으므로, 이름 붙은 영역이 필요하면 감싸는 쪽에서 직접 역할과 이름을 부여해야 합니다.
전역으로 적용되는 축(동작 줄이기·고대비·색 강제 모드·최소 터치 타겟)은 전역 접근성 축에 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 행 배치 | Column 안 Row · Expanded 텍스트 | 행·번호·prefix·본문 DOM 래퍼 |
| 추가·삭제 밴드 | 불투명 컨테이너 토큰 | 불투명 컨테이너 토큰 (resolver가 행별 CSS 색을 구성) |
| 활성 행 | Clickable 의 Gesture/키보드 | Clickable 의 DOM/키보드 |
| 호스트 표면 | 호출부 래퍼 | id / 속성 / 이벤트를 OutlinedContainer 루트에 전달 |
관련 컴포넌트 (Related Components)#
- CodeSnippet: 단일 코드 블록 표시
- Diff: 이미지/텍스트 split-view 비교