@a4ui/core 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +446 -316
- package/dist/elements.css +95 -0
- package/dist/full.css +95 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +5107 -4227
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/Globe.d.ts +44 -0
- package/dist/ui/ModelPicker.d.ts +38 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +24 -1
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/Globe.tsx +317 -0
- package/src/ui/ModelPicker.tsx +119 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/WorldMap.tsx +191 -0
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.33.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -186,6 +186,29 @@ export { Dock, type DockItem, type DockProps } from './ui/Dock'
|
|
|
186
186
|
export { AnimatedBeam, type AnimatedBeamProps } from './ui/AnimatedBeam'
|
|
187
187
|
export { BorderBeam, type BorderBeamProps } from './ui/BorderBeam'
|
|
188
188
|
export { Meteors, type MeteorsProps } from './ui/Meteors'
|
|
189
|
+
|
|
190
|
+
// AI agent surface + chat affordances.
|
|
191
|
+
export { ReasoningTrace, type ReasoningTraceProps } from './ui/ReasoningTrace'
|
|
192
|
+
export {
|
|
193
|
+
ToolCallTimeline,
|
|
194
|
+
type ToolCall,
|
|
195
|
+
type ToolCallStatus,
|
|
196
|
+
type ToolCallTimelineProps,
|
|
197
|
+
} from './ui/ToolCallTimeline'
|
|
198
|
+
export { DiffViewer, type DiffLine, type DiffViewerProps } from './ui/DiffViewer'
|
|
199
|
+
export { ModelPicker, type ModelOption, type ModelPickerProps } from './ui/ModelPicker'
|
|
200
|
+
export { UsageMeter, type UsageMeterProps } from './ui/UsageMeter'
|
|
201
|
+
export { SuggestionChips, type SuggestionChipsProps } from './ui/SuggestionChips'
|
|
202
|
+
|
|
203
|
+
// Content blocks.
|
|
204
|
+
export { Callout, type CalloutTone, type CalloutProps } from './ui/Callout'
|
|
205
|
+
export { Snippet, type SnippetProps } from './ui/Snippet'
|
|
206
|
+
|
|
207
|
+
// Spatial showpieces + motion extras.
|
|
208
|
+
export { Globe, type GlobeMarker, type GlobeArc, type GlobeProps } from './ui/Globe'
|
|
209
|
+
export { WorldMap, type WorldMapPoint, type WorldMapConnection, type WorldMapProps } from './ui/WorldMap'
|
|
210
|
+
export { Confetti, fireConfetti, type ConfettiProps } from './ui/Confetti'
|
|
211
|
+
export { CursorTrail, type CursorTrailProps } from './ui/CursorTrail'
|
|
189
212
|
export {
|
|
190
213
|
CopyButton,
|
|
191
214
|
type CopyButtonProps,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Prose-embed highlight block for docs/guidance content. Distinct from Alert
|
|
2
|
+
// (a dismissible notification banner): Callout sits inline in a body of text —
|
|
3
|
+
// left accent border, tinted background, comfortable padding — and is never
|
|
4
|
+
// dismissed. Tone tokens are shared with Alert so info/success/warning/danger
|
|
5
|
+
// read identically across both components.
|
|
6
|
+
import { CircleCheck, CircleX, Info, TriangleAlert } from 'lucide-solid'
|
|
7
|
+
import type { Component, JSX } from 'solid-js'
|
|
8
|
+
import { Show } from 'solid-js'
|
|
9
|
+
import { Dynamic } from 'solid-js/web'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
|
|
13
|
+
/** Semantic tone of a {@link Callout}; drives the accent border, tint, and default icon. */
|
|
14
|
+
export type CalloutTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
15
|
+
|
|
16
|
+
const TONE: Record<CalloutTone, { wrap: string; icon: string; Icon: Component<{ class?: string }> }> = {
|
|
17
|
+
info: { wrap: 'border-sky-500 bg-sky-500/10', icon: 'text-sky-500', Icon: Info },
|
|
18
|
+
success: { wrap: 'border-emerald-500 bg-emerald-500/10', icon: 'text-emerald-500', Icon: CircleCheck },
|
|
19
|
+
warning: { wrap: 'border-amber-500 bg-amber-500/10', icon: 'text-amber-500', Icon: TriangleAlert },
|
|
20
|
+
danger: { wrap: 'border-rose-500 bg-rose-500/10', icon: 'text-rose-500', Icon: CircleX },
|
|
21
|
+
neutral: { wrap: 'border-border bg-muted', icon: 'text-muted-foreground', Icon: Info },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CalloutProps {
|
|
25
|
+
/** Visual/semantic tone. Defaults to `'info'`. */
|
|
26
|
+
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
27
|
+
/** Optional bold heading shown above the body. */
|
|
28
|
+
title?: JSX.Element
|
|
29
|
+
/** Overrides the tone's default icon. */
|
|
30
|
+
icon?: JSX.Element
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
class?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Inline highlighted block for embedding guidance/notes inside prose (docs,
|
|
37
|
+
* READMEs, long-form content) — a left accent border, a faint tone-tinted
|
|
38
|
+
* background, and an icon. Unlike {@link Alert} it isn't a dismissible
|
|
39
|
+
* notification; it's part of the content flow.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <Callout tone="warning" title="Before you continue">
|
|
44
|
+
* This action can't be undone once the migration starts.
|
|
45
|
+
* </Callout>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function Callout(props: CalloutProps): JSX.Element {
|
|
49
|
+
const tone = () => TONE[props.tone ?? 'info']
|
|
50
|
+
return (
|
|
51
|
+
<div class={cn('flex gap-3 rounded-lg border-l-4 p-4 text-sm text-foreground', tone().wrap, props.class)}>
|
|
52
|
+
<Show
|
|
53
|
+
when={props.icon}
|
|
54
|
+
fallback={<Dynamic component={tone().Icon} class={cn('mt-0.5 h-4 w-4 shrink-0', tone().icon)} />}
|
|
55
|
+
>
|
|
56
|
+
{props.icon}
|
|
57
|
+
</Show>
|
|
58
|
+
<div class="flex-1">
|
|
59
|
+
<Show when={props.title}>
|
|
60
|
+
<p class="font-semibold">{props.title}</p>
|
|
61
|
+
</Show>
|
|
62
|
+
<div class="text-muted-foreground">{props.children}</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Decorative confetti burst: a spawn-then-remove particle idiom (same as
|
|
2
|
+
// Ripple.tsx) driven by the native Web Animations API — each piece is a
|
|
3
|
+
// one-shot element with nothing for a JS animation engine to add. Angle and
|
|
4
|
+
// velocity per piece are derived deterministically from the piece's index
|
|
5
|
+
// (via Math.sin), not Math.random, so a burst is reproducible and doesn't
|
|
6
|
+
// depend on an RNG.
|
|
7
|
+
import { createEffect, on, type JSX } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
|
|
12
|
+
export interface ConfettiProps {
|
|
13
|
+
/** Bump this (e.g. a counter) to fire a burst. Mount does not count. */
|
|
14
|
+
trigger?: number
|
|
15
|
+
/** Number of pieces per burst. @default 80 */
|
|
16
|
+
count?: number
|
|
17
|
+
class?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONFETTI_COLORS = ['hsl(var(--primary))', 'hsl(var(--accent))', 'hsl(var(--foreground))']
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fires one burst of confetti pieces from the bottom-center of `container`,
|
|
24
|
+
* flying up and outward in a fan, rotating, then falling with gravity and
|
|
25
|
+
* fading out — each piece removes itself once its animation finishes. Angle,
|
|
26
|
+
* speed, fall distance and spin are derived deterministically from each
|
|
27
|
+
* piece's index (Math.sin), so the same `count` always produces the same
|
|
28
|
+
* fan. `container` should be `position: relative` (or already positioned)
|
|
29
|
+
* with `overflow: hidden` so pieces don't affect layout. No-op under
|
|
30
|
+
* {@link motionReduced}. This is the primitive behind {@link Confetti}.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* fireConfetti(cardEl, { count: 120 })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function fireConfetti(container: HTMLElement, opts: { count?: number } = {}): void {
|
|
38
|
+
if (motionReduced()) return
|
|
39
|
+
const count = opts.count ?? 80
|
|
40
|
+
const rect = container.getBoundingClientRect()
|
|
41
|
+
const originX = rect.width / 2
|
|
42
|
+
const originY = rect.height
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < count; i++) {
|
|
45
|
+
// Fan the burst across a ~126° arc centered straight up; wobble is a
|
|
46
|
+
// deterministic pseudo-random 0..1 derived from the index.
|
|
47
|
+
const spread = Math.PI * 0.7
|
|
48
|
+
const wobble = Math.sin(i * 12.9898) * 0.5 + 0.5
|
|
49
|
+
const angle = -Math.PI / 2 + (wobble - 0.5) * spread
|
|
50
|
+
const speed = 180 + (Math.sin(i * 4.567) * 0.5 + 0.5) * 160
|
|
51
|
+
const dx = Math.cos(angle) * speed
|
|
52
|
+
const dy = Math.sin(angle) * speed
|
|
53
|
+
const fall = 200 + (Math.sin(i * 5.113) * 0.5 + 0.5) * 160
|
|
54
|
+
const rotation = 240 + (Math.sin(i * 3.377) * 0.5 + 0.5) * 640
|
|
55
|
+
const duration = 900 + (Math.sin(i * 2.1) * 0.5 + 0.5) * 500
|
|
56
|
+
|
|
57
|
+
const el = document.createElement('span')
|
|
58
|
+
el.setAttribute('aria-hidden', 'true')
|
|
59
|
+
Object.assign(el.style, {
|
|
60
|
+
position: 'absolute',
|
|
61
|
+
left: `${originX}px`,
|
|
62
|
+
top: `${originY}px`,
|
|
63
|
+
width: `${5 + (i % 3) * 2}px`,
|
|
64
|
+
height: `${10 + (i % 4) * 2}px`,
|
|
65
|
+
borderRadius: '1px',
|
|
66
|
+
background: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
|
67
|
+
pointerEvents: 'none',
|
|
68
|
+
})
|
|
69
|
+
container.appendChild(el)
|
|
70
|
+
|
|
71
|
+
const animation = el.animate(
|
|
72
|
+
[
|
|
73
|
+
{ transform: 'translate(-50%,-50%) translate(0px,0px) rotate(0deg)', opacity: '1' },
|
|
74
|
+
{
|
|
75
|
+
transform: `translate(-50%,-50%) translate(${dx * 0.7}px,${dy}px) rotate(${rotation * 0.5}deg)`,
|
|
76
|
+
opacity: '1',
|
|
77
|
+
offset: 0.45,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
transform: `translate(-50%,-50%) translate(${dx}px,${dy + fall}px) rotate(${rotation}deg)`,
|
|
81
|
+
opacity: '0',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
{ duration, easing: 'cubic-bezier(0.2, 0.6, 0.4, 1)' },
|
|
85
|
+
)
|
|
86
|
+
const done = (): void => el.remove()
|
|
87
|
+
animation.finished.then(done).catch(done)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Absolutely-positioned confetti layer: fires a burst of colored pieces from
|
|
93
|
+
* the bottom-center whenever `trigger` changes (the initial mount does not
|
|
94
|
+
* fire one). Purely decorative — `pointer-events-none` and `aria-hidden`.
|
|
95
|
+
* Place it inside a `position: relative` container; pair with a `count`
|
|
96
|
+
* prop, or reach for {@link fireConfetti} directly to fire into an
|
|
97
|
+
* arbitrary element outside Solid's render tree.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* const [bursts, setBursts] = createSignal(0)
|
|
102
|
+
* return (
|
|
103
|
+
* <div class="relative h-64">
|
|
104
|
+
* <Confetti trigger={bursts()} count={100} />
|
|
105
|
+
* <Button onClick={() => setBursts((n) => n + 1)}>Celebrate</Button>
|
|
106
|
+
* </div>
|
|
107
|
+
* )
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function Confetti(props: ConfettiProps): JSX.Element {
|
|
111
|
+
let containerEl: HTMLDivElement | undefined
|
|
112
|
+
|
|
113
|
+
createEffect(
|
|
114
|
+
on(
|
|
115
|
+
() => props.trigger,
|
|
116
|
+
() => {
|
|
117
|
+
if (!containerEl) return
|
|
118
|
+
fireConfetti(containerEl, { count: props.count })
|
|
119
|
+
},
|
|
120
|
+
{ defer: true },
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
ref={containerEl}
|
|
127
|
+
class={cn('absolute inset-0 overflow-hidden pointer-events-none', props.class)}
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Decorative cursor trail: same spawn-then-remove particle idiom as
|
|
2
|
+
// Ripple.tsx / Confetti.tsx, driven by the native Web Animations API. The
|
|
3
|
+
// layer itself is pointer-events-none, so the listener lives on the
|
|
4
|
+
// `relative` parent that hosts it.
|
|
5
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface CursorTrailProps {
|
|
11
|
+
/** Blob tone. @default 'primary' */
|
|
12
|
+
color?: 'primary' | 'accent'
|
|
13
|
+
/** Blob diameter in px. @default 24 */
|
|
14
|
+
size?: number
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SPAWN_THROTTLE_MS = 20
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Layer that spawns a soft, blurred blob at the cursor position on every
|
|
22
|
+
* throttled `pointermove` over its parent, scaling down and fading out as
|
|
23
|
+
* it removes itself — leaving a trail. Must sit inside a `position:
|
|
24
|
+
* relative` parent (the layer listens on `parentElement`, since it is
|
|
25
|
+
* itself `pointer-events-none`). Purely decorative — `pointer-events-none`
|
|
26
|
+
* and `aria-hidden`. No-op under {@link motionReduced}; listeners are
|
|
27
|
+
* cleaned up on unmount.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <div class="relative h-48 rounded-lg border">
|
|
32
|
+
* <CursorTrail color="accent" size={32} />
|
|
33
|
+
* </div>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function CursorTrail(props: CursorTrailProps): JSX.Element {
|
|
37
|
+
let layerEl: HTMLDivElement | undefined
|
|
38
|
+
let lastSpawn = 0
|
|
39
|
+
|
|
40
|
+
const handlePointerMove = (event: PointerEvent): void => {
|
|
41
|
+
if (motionReduced() || !layerEl) return
|
|
42
|
+
const now = performance.now()
|
|
43
|
+
if (now - lastSpawn < SPAWN_THROTTLE_MS) return
|
|
44
|
+
lastSpawn = now
|
|
45
|
+
|
|
46
|
+
const rect = layerEl.getBoundingClientRect()
|
|
47
|
+
const x = event.clientX - rect.left
|
|
48
|
+
const y = event.clientY - rect.top
|
|
49
|
+
const size = props.size ?? 24
|
|
50
|
+
const tone = props.color === 'accent' ? 'accent' : 'primary'
|
|
51
|
+
|
|
52
|
+
const el = document.createElement('span')
|
|
53
|
+
el.setAttribute('aria-hidden', 'true')
|
|
54
|
+
Object.assign(el.style, {
|
|
55
|
+
position: 'absolute',
|
|
56
|
+
left: `${x}px`,
|
|
57
|
+
top: `${y}px`,
|
|
58
|
+
width: `${size}px`,
|
|
59
|
+
height: `${size}px`,
|
|
60
|
+
borderRadius: '9999px',
|
|
61
|
+
background: `hsl(var(--${tone}) / 0.35)`,
|
|
62
|
+
filter: 'blur(6px)',
|
|
63
|
+
pointerEvents: 'none',
|
|
64
|
+
transform: 'translate(-50%,-50%) scale(1)',
|
|
65
|
+
})
|
|
66
|
+
layerEl.appendChild(el)
|
|
67
|
+
|
|
68
|
+
const animation = el.animate(
|
|
69
|
+
[
|
|
70
|
+
{ transform: 'translate(-50%,-50%) scale(1)', opacity: '1' },
|
|
71
|
+
{ transform: 'translate(-50%,-50%) scale(0.2)', opacity: '0' },
|
|
72
|
+
],
|
|
73
|
+
{ duration: 500, easing: 'ease-out' },
|
|
74
|
+
)
|
|
75
|
+
const done = (): void => el.remove()
|
|
76
|
+
animation.finished.then(done).catch(done)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onMount(() => {
|
|
80
|
+
const parent = layerEl?.parentElement
|
|
81
|
+
parent?.addEventListener('pointermove', handlePointerMove)
|
|
82
|
+
onCleanup(() => parent?.removeEventListener('pointermove', handlePointerMove))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div ref={layerEl} class={cn('absolute inset-0 pointer-events-none', props.class)} aria-hidden="true" />
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Read-only unified-diff / line-diff renderer. Reuses the CodeTabs monospace
|
|
2
|
+
// idiom (rounded border, bg-card, font-mono text-sm) and the Alert/Badge tone
|
|
3
|
+
// tokens: `emerald` for added (their `success` tone) and the `destructive`
|
|
4
|
+
// CSS-var token for removed. No syntax highlighting.
|
|
5
|
+
import { type JSX, Show } from 'solid-js'
|
|
6
|
+
import { createMemo, For } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
/** A single rendered row of a diff. */
|
|
11
|
+
export interface DiffLine {
|
|
12
|
+
type: 'add' | 'remove' | 'context'
|
|
13
|
+
content: string
|
|
14
|
+
/** Line number in the old file. Present for `'remove'` and `'context'` rows. */
|
|
15
|
+
oldNum?: number
|
|
16
|
+
/** Line number in the new file. Present for `'add'` and `'context'` rows. */
|
|
17
|
+
newNum?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiffViewerProps {
|
|
21
|
+
/** Unified diff text (as produced by `git diff`/`diff -u`). Ignored if `lines` is set. */
|
|
22
|
+
diff?: string
|
|
23
|
+
/** Pre-parsed diff rows. Takes precedence over `diff`. */
|
|
24
|
+
lines?: DiffLine[]
|
|
25
|
+
/** Header label. If omitted, inferred from the `+++`/`---` headers of `diff`, when present. */
|
|
26
|
+
filename?: string
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MARKER: Record<DiffLine['type'], string> = { add: '+', remove: '-', context: ' ' }
|
|
31
|
+
|
|
32
|
+
const ROW_BG: Record<DiffLine['type'], string> = {
|
|
33
|
+
add: 'bg-emerald-500/10',
|
|
34
|
+
remove: 'bg-destructive/10',
|
|
35
|
+
context: '',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MARKER_TONE: Record<DiffLine['type'], string> = {
|
|
39
|
+
add: 'text-emerald-500',
|
|
40
|
+
remove: 'text-destructive',
|
|
41
|
+
context: 'text-muted-foreground',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CONTENT_TONE: Record<DiffLine['type'], string> = {
|
|
45
|
+
add: 'text-foreground',
|
|
46
|
+
remove: 'text-foreground',
|
|
47
|
+
context: 'text-muted-foreground',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
51
|
+
|
|
52
|
+
/** Parses a unified diff string into rows plus an optional inferred filename. */
|
|
53
|
+
function parseUnifiedDiff(diff: string): { lines: DiffLine[]; filename?: string } {
|
|
54
|
+
const rawLines = diff.split('\n')
|
|
55
|
+
// Drop the trailing empty element produced when `diff` ends with a newline.
|
|
56
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop()
|
|
57
|
+
|
|
58
|
+
const lines: DiffLine[] = []
|
|
59
|
+
let filename: string | undefined
|
|
60
|
+
let oldNum = 0
|
|
61
|
+
let newNum = 0
|
|
62
|
+
|
|
63
|
+
for (const raw of rawLines) {
|
|
64
|
+
if (raw.startsWith('+++ ') || raw.startsWith('--- ')) {
|
|
65
|
+
const path = raw.slice(4).trim()
|
|
66
|
+
if (raw.startsWith('+++ ') && path !== '/dev/null') filename = path.replace(/^[ab]\//, '')
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const hunkMatch = HUNK_HEADER.exec(raw)
|
|
70
|
+
if (hunkMatch) {
|
|
71
|
+
oldNum = Number(hunkMatch[1])
|
|
72
|
+
newNum = Number(hunkMatch[2])
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (raw.startsWith('\\')) continue // ""
|
|
76
|
+
|
|
77
|
+
if (raw.startsWith('+')) {
|
|
78
|
+
lines.push({ type: 'add', content: raw.slice(1), newNum: newNum++ })
|
|
79
|
+
} else if (raw.startsWith('-')) {
|
|
80
|
+
lines.push({ type: 'remove', content: raw.slice(1), oldNum: oldNum++ })
|
|
81
|
+
} else {
|
|
82
|
+
lines.push({
|
|
83
|
+
type: 'context',
|
|
84
|
+
content: raw.startsWith(' ') ? raw.slice(1) : raw,
|
|
85
|
+
oldNum: oldNum++,
|
|
86
|
+
newNum: newNum++,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { lines, filename }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read-only diff block: renders added/removed/context lines with a line-number
|
|
96
|
+
* gutter and a +/-/space marker. Accepts either a raw unified-diff string
|
|
97
|
+
* (parsed internally) or pre-parsed {@link DiffLine} rows.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* <DiffViewer
|
|
102
|
+
* filename="src/lib/cn.ts"
|
|
103
|
+
* diff={`@@ -1,3 +1,3 @@\n-export const cn = (a) => a\n+export const cn = (a, b) => a + b\n context line`}
|
|
104
|
+
* />
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function DiffViewer(props: DiffViewerProps): JSX.Element {
|
|
108
|
+
const parsed = createMemo(() =>
|
|
109
|
+
props.lines ? { lines: props.lines, filename: undefined } : parseUnifiedDiff(props.diff ?? ''),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const rows = () => parsed().lines
|
|
113
|
+
const displayFilename = () => props.filename ?? parsed().filename
|
|
114
|
+
|
|
115
|
+
const counts = createMemo(() => {
|
|
116
|
+
let added = 0
|
|
117
|
+
let removed = 0
|
|
118
|
+
for (const line of rows()) {
|
|
119
|
+
if (line.type === 'add') added++
|
|
120
|
+
else if (line.type === 'remove') removed++
|
|
121
|
+
}
|
|
122
|
+
return { added, removed }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const ariaLabel = () => {
|
|
126
|
+
const { added, removed } = counts()
|
|
127
|
+
const name = displayFilename()
|
|
128
|
+
return `Diff${name ? ` for ${name}` : ''}: ${added} added, ${removed} removed`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
role="figure"
|
|
134
|
+
aria-label={ariaLabel()}
|
|
135
|
+
class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}
|
|
136
|
+
>
|
|
137
|
+
<Show when={displayFilename()}>
|
|
138
|
+
<div class="border-b border-border px-4 py-2 text-sm font-medium text-foreground">
|
|
139
|
+
{displayFilename()}
|
|
140
|
+
</div>
|
|
141
|
+
</Show>
|
|
142
|
+
<div class="overflow-x-auto">
|
|
143
|
+
<div class="min-w-max font-mono text-sm">
|
|
144
|
+
<For each={rows()}>
|
|
145
|
+
{(line) => (
|
|
146
|
+
<div class={cn('flex', ROW_BG[line.type])}>
|
|
147
|
+
<span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
|
|
148
|
+
{line.oldNum ?? ''}
|
|
149
|
+
</span>
|
|
150
|
+
<span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
|
|
151
|
+
{line.newNum ?? ''}
|
|
152
|
+
</span>
|
|
153
|
+
<span class={cn('w-5 shrink-0 select-none py-0.5 text-center', MARKER_TONE[line.type])}>
|
|
154
|
+
{MARKER[line.type]}
|
|
155
|
+
</span>
|
|
156
|
+
<span class={cn('flex-1 whitespace-pre py-0.5 pr-4', CONTENT_TONE[line.type])}>
|
|
157
|
+
{line.content}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</For>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|