Skip to content

Commit 224f4d0

Browse files
committed
feat(git): resolve author avatars, tighter commit rows
- Resolve real commit-author portraits (GitHub avatar for noreply emails, otherwise Gravatar via a SHA-256 email hash), painting over the initials chip and falling back to it on error or offline. - Tighten the commit list another notch (shorter rows, narrower lane gutter). - Fix the `pnpm dev` harness: a bare `NEXT_PUBLIC_DEVFRAME_WS` port now resolves to a same-host socket on the backend's WS route instead of being mis-read as a path.
1 parent e055091 commit 224f4d0

3 files changed

Lines changed: 78 additions & 12 deletions

File tree

plugins/git/src/client/components/rpc-provider.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use client'
22

33
import type { DevframeRpcClient } from 'devframe/client'
4+
import type { ConnectionMeta } from 'devframe/types'
45
import type { ReactNode } from 'react'
56
import { connectDevframe } from 'devframe/client'
7+
import { DEVFRAME_WS_ROUTE } from 'devframe/constants'
68
import { createContext, use, useEffect, useState } from 'react'
79

810
interface ConnectionState {
@@ -28,8 +30,13 @@ export function RpcProvider({ children }: { children: ReactNode }) {
2830
// Next.js statically inlines `process.env.NEXT_PUBLIC_*` at build time.
2931
// eslint-disable-next-line node/prefer-global/process
3032
const devWs = process.env.NEXT_PUBLIC_DEVFRAME_WS
31-
const options = devWs
32-
? { connectionMeta: { backend: 'websocket' as const, websocket: devWs } }
33+
// A bare port (what `scripts/dev.mjs` sets) becomes a same-host socket on
34+
// the backend's WS route; a full `ws(s)://` URL is used verbatim.
35+
const websocket: ConnectionMeta['websocket'] | undefined = devWs
36+
? (/^\d+$/.test(devWs) ? { port: Number(devWs), path: DEVFRAME_WS_ROUTE } : devWs)
37+
: undefined
38+
const options = websocket
39+
? { connectionMeta: { backend: 'websocket' as const, websocket } }
3340
: undefined
3441
connectDevframe(options).then(
3542
(rpc) => {

plugins/git/src/client/components/ui/avatar.tsx

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
'use client'
2+
13
import type * as React from 'react'
4+
import { useEffect, useState } from 'react'
25
import { cn } from '../../lib/utils'
36

4-
// A deterministic, offline avatar: initials drawn on a stable background color
5-
// derived from the author's identity. No network round-trip (keeping the
6-
// dashboard self-contained), yet every author reads as a distinct chip — the
7-
// same visual role the photo avatar plays in the commit hover card.
7+
// A commit author avatar. We try to resolve a real portrait from the author's
8+
// identity — a GitHub avatar for `noreply` commit emails, otherwise Gravatar —
9+
// and fall back to a deterministic initials chip when there's no image (or no
10+
// network). The chip is always rendered underneath, so the image simply paints
11+
// over it once it loads and reappears on error.
812

913
// Saturated-but-legible hues that sit well behind white text in both themes.
1014
const AVATAR_COLORS = [
@@ -38,26 +42,81 @@ function initialsOf(name: string, email?: string): string {
3842
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
3943
}
4044

45+
async function sha256Hex(input: string): Promise<string> {
46+
const bytes = new TextEncoder().encode(input)
47+
const digest = await crypto.subtle.digest('SHA-256', bytes)
48+
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
49+
}
50+
51+
// `[<n>+]<user>@users.noreply.github.com` → the GitHub username.
52+
const GITHUB_NOREPLY = /^(?:\d+\+)?([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)@users\.noreply\.github\.com$/i
53+
54+
/**
55+
* Resolve an avatar image URL for a commit email. GitHub noreply addresses map
56+
* straight to the user's GitHub avatar; everything else resolves via Gravatar
57+
* (`d=404` so unregistered emails fail the request and fall back to initials).
58+
* Returns `null` when no email or no crypto (insecure context) is available.
59+
*/
60+
async function resolveAvatarUrl(email: string, size: number): Promise<string | null> {
61+
const normalized = email.trim().toLowerCase()
62+
if (!normalized || !normalized.includes('@'))
63+
return null
64+
65+
const github = normalized.match(GITHUB_NOREPLY)
66+
if (github)
67+
return `https://github.com/${github[1]}.png?size=${size}`
68+
69+
if (!globalThis.crypto?.subtle)
70+
return null
71+
const hash = await sha256Hex(normalized)
72+
return `https://gravatar.com/avatar/${hash}?s=${size}&d=404`
73+
}
74+
4175
export function Avatar({
4276
name,
4377
email,
78+
size = 64,
4479
className,
4580
...props
46-
}: { name: string, email?: string } & React.ComponentProps<'span'>) {
81+
}: { name: string, email?: string, size?: number } & React.ComponentProps<'span'>) {
4782
const key = (email || name || '?').toLowerCase()
4883
const color = AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length]
84+
const [src, setSrc] = useState<string | null>(null)
85+
const [failed, setFailed] = useState(false)
86+
87+
useEffect(() => {
88+
let alive = true
89+
setSrc(null)
90+
setFailed(false)
91+
resolveAvatarUrl(email ?? '', size)
92+
.then(url => alive && setSrc(url))
93+
.catch(() => {})
94+
return () => {
95+
alive = false
96+
}
97+
}, [email, size])
98+
4999
return (
50100
<span
51-
aria-hidden
52101
className={cn(
53-
'inline-flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold text-white select-none',
102+
'relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-semibold text-white select-none',
54103
className,
55104
)}
56105
style={{ backgroundColor: color }}
57106
title={name}
58107
{...props}
59108
>
60109
{initialsOf(name, email)}
110+
{src && !failed && (
111+
<img
112+
src={src}
113+
alt=""
114+
loading="lazy"
115+
referrerPolicy="no-referrer"
116+
onError={() => setFailed(true)}
117+
className="absolute inset-0 size-full rounded-full object-cover"
118+
/>
119+
)}
61120
</span>
62121
)
63122
}

plugins/git/src/client/components/views/log-panel-view.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ import { IconButton } from '../ui/button'
2626
import { Icon } from '../ui/icon'
2727
import { Skeleton } from '../ui/skeleton'
2828

29-
const ROW_H = 36
30-
const COL_W = 16
31-
const NODE_R = 4.5
29+
const ROW_H = 30
30+
const COL_W = 15
31+
const NODE_R = 4
3232
const PAD_L = 8
3333

3434
export interface LogPanelViewProps {

0 commit comments

Comments
 (0)