Skip to content

Commit 49604aa

Browse files
committed
feat(plugin-git): light/dark theme, commit graph, and write actions
- Light/dark mode following the system preference with a manual toggle and a no-flash inline script; theme tokens already shipped in globals.css. - SourceTree-style commit graph in the log: git:log now returns parent hashes (--topo-order) and the client computes lanes and draws colored SVG edges/nodes alongside fixed-height commit rows. - Stage / unstage / commit from the UI via gated git:stage, git:unstage, and git:commit actions (createGitDevframe({ write: true }) or --write). Status reports canWrite; the UI exposes per-file and bulk controls plus a commit box only when write mode is on over a live connection.
1 parent e3b353b commit 49604aa

21 files changed

Lines changed: 797 additions & 76 deletions

File tree

plugins/git/README.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# @devframes/plugin-git
22

33
Git integration for [devframe](https://github.com/devframes/devframe) — a
4-
read-only repository dashboard (status, log, branches, diff) with a **Next.js
5-
App Router + shadcn/ui** SPA over type-safe RPC. The host process shells out to
6-
`git` and exposes the repository; the same bundle runs as a live dev server or
7-
a fully static deployment.
4+
repository dashboard with a **Next.js App Router + shadcn/ui** SPA over
5+
type-safe RPC. The host process shells out to `git` and exposes the repository;
6+
the same bundle runs as a live dev server or a fully static deployment.
7+
8+
Status, a SourceTree-style **commit graph**, branches, and diffs are read-only;
9+
staging, unstaging, and committing are available when write mode is enabled. The
10+
UI follows the system **light/dark** preference with a manual toggle.
811

912
## Install
1013

@@ -18,6 +21,7 @@ Run the dashboard against the current repository:
1821

1922
```sh
2023
npx devframe-git # dev server (live RPC over WebSocket)
24+
npx devframe-git --write # also enable staging / committing from the UI
2125
npx devframe-git build # static deploy → dist-static/
2226
npx devframe-git --port 4000
2327
```
@@ -40,20 +44,31 @@ await createCli(createGitDevframe({ repoRoot: process.cwd() })).parse()
4044
| `basePath` | adapter-resolved | Mount path (`/` standalone, `/__git/` hosted). |
4145
| `distDir` | bundled SPA | Override the served SPA directory. |
4246
| `port` | `9710` | Preferred dev-server port. |
47+
| `write` | `false` | Enable staging, unstaging, and committing from the UI. |
4348

4449
## RPC surface
4550

46-
Every function is a `query` with `snapshot: true`: resolved live over WebSocket
47-
in dev, and served from a snapshot baked at build time for static deploys. Each
48-
degrades to an empty, `isRepo: false` result outside a git repository.
51+
The read functions are each a `query` with `snapshot: true`: resolved live over
52+
WebSocket in dev, and served from a snapshot baked at build time for static
53+
deploys. Each degrades to an empty, `isRepo: false` result outside a git
54+
repository.
4955

5056
- `git:status` — branch, upstream tracking (ahead/behind), staged / unstaged /
51-
untracked files, parsed from `git status --porcelain=v2`.
52-
- `git:log` — paginated commit history (`limit` / `skip`).
57+
untracked files, parsed from `git status --porcelain=v2`. Reports `canWrite`.
58+
- `git:log` — paginated commit history (`limit` / `skip`) including parent
59+
hashes, which drive the commit graph.
5360
- `git:branches` — local branches with SHA, upstream, ahead/behind, tip subject.
5461
- `git:diff` — per-file added/deleted counts for the working tree or index, plus
5562
a unified patch for a selected file.
5663

64+
Write actions are `action` functions, registered only when write mode is enabled
65+
(`createGitDevframe({ write: true })` or the `--write` flag) and gated behind
66+
`status.canWrite` in the UI. Each returns fresh status (commit returns a result):
67+
68+
- `git:stage``git add` the given paths.
69+
- `git:unstage``git restore --staged` the given paths.
70+
- `git:commit` — commit the staged changes with a message.
71+
5772
## Develop
5873

5974
```sh

plugins/git/src/client/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ export const metadata: Metadata = {
77
description: 'A devframe Git integration with a Next.js App Router + shadcn/ui SPA.',
88
}
99

10+
// Set the theme class before paint to avoid a flash of the wrong theme.
11+
const themeScript = `(function(){try{var k='devframe-git-theme';var t=localStorage.getItem(k);if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='dark')document.documentElement.classList.add('dark');}catch(e){document.documentElement.classList.add('dark');}})();`
12+
1013
export default function RootLayout({ children }: { children: ReactNode }) {
1114
return (
12-
<html lang="en" className="dark">
15+
<html lang="en" suppressHydrationWarning>
16+
<head>
17+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
18+
</head>
1319
<body>{children}</body>
1420
</html>
1521
)

plugins/git/src/client/components/dashboard.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
'use client'
22

3-
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree } from 'lucide-react'
3+
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree, Moon, Sun } from 'lucide-react'
44
import { BranchesPanel } from './branches-panel'
55
import { DiffPanel } from './diff-panel'
66
import { LogPanel } from './log-panel'
77
import { RpcProvider, useRpc } from './rpc-provider'
88
import { StatusPanel } from './status-panel'
9+
import { useTheme } from './theme'
910
import { Badge } from './ui/badge'
11+
import { Button } from './ui/button'
1012
import { Card, CardContent } from './ui/card'
1113
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
1214

@@ -24,6 +26,15 @@ function ConnectionBadge() {
2426
)
2527
}
2628

29+
function ThemeToggle() {
30+
const { theme, toggle } = useTheme()
31+
return (
32+
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle light/dark theme">
33+
{theme === 'dark' ? <Sun /> : <Moon />}
34+
</Button>
35+
)
36+
}
37+
2738
export function Dashboard() {
2839
return (
2940
<RpcProvider>
@@ -38,7 +49,10 @@ export function Dashboard() {
3849
</p>
3950
</div>
4051
</div>
41-
<ConnectionBadge />
52+
<div className="flex items-center gap-2">
53+
<ConnectionBadge />
54+
<ThemeToggle />
55+
</div>
4256
</header>
4357

4458
<Tabs defaultValue="status">

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

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22

33
import type { DevframeRpcClient } from 'devframe/client'
44
import type { Commit } from '../../index'
5+
import type { GraphRow } from '../lib/commit-graph'
56
import { RefreshCw } from 'lucide-react'
6-
import { useCallback, useState } from 'react'
7+
import { useCallback, useMemo, useState } from 'react'
8+
import { computeGraph } from '../lib/commit-graph'
79
import { Badge } from './ui/badge'
810
import { Button } from './ui/button'
911
import { ScrollArea } from './ui/scroll-area'
1012
import { Skeleton } from './ui/skeleton'
1113
import { useRpcResource } from './use-rpc-resource'
1214

1315
const PAGE = 30
16+
const ROW_H = 54
17+
const COL_W = 14
18+
const NODE_R = 4.5
1419

1520
function relativeTime(epoch: number): string {
1621
const diff = Date.now() - epoch
@@ -28,20 +33,65 @@ function relativeTime(epoch: number): string {
2833
return new Date(epoch).toLocaleDateString()
2934
}
3035

31-
function CommitRow({ commit }: { commit: Commit }) {
36+
function cx(col: number): number {
37+
return col * COL_W + COL_W / 2
38+
}
39+
40+
function linkPath(fromCol: number, fromY: number, toCol: number, toY: number): string {
41+
const x1 = cx(fromCol)
42+
const x2 = cx(toCol)
43+
if (fromCol === toCol)
44+
return `M ${x1} ${fromY} L ${x2} ${toY}`
45+
const midY = (fromY + toY) / 2
46+
return `M ${x1} ${fromY} C ${x1} ${midY} ${x2} ${midY} ${x2} ${toY}`
47+
}
48+
49+
function GraphCell({ row, width }: { row: GraphRow, width: number }) {
50+
const mid = ROW_H / 2
3251
return (
33-
<li className="border-border/60 flex flex-col gap-1 border-b py-2 last:border-0">
34-
<div className="flex items-baseline gap-2">
35-
<code className="text-muted-foreground shrink-0 text-xs">{commit.shortHash}</code>
36-
<span className="truncate text-sm font-medium">{commit.subject}</span>
37-
</div>
38-
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
39-
<span>{commit.author}</span>
40-
<span aria-hidden>·</span>
41-
<span title={new Date(commit.date).toLocaleString()}>{relativeTime(commit.date)}</span>
42-
{commit.refs.map(ref => (
43-
<Badge key={ref} variant="outline" className="px-1.5 py-0 font-mono text-[10px]">{ref}</Badge>
44-
))}
52+
<svg width={width} height={ROW_H} className="block shrink-0" style={{ overflow: 'visible' }} aria-hidden>
53+
{row.topLinks.map((link, i) => (
54+
<path
55+
key={`t${i}`}
56+
d={linkPath(link.from, 0, link.to, mid)}
57+
fill="none"
58+
stroke={link.color}
59+
strokeWidth={1.6}
60+
strokeLinecap="round"
61+
/>
62+
))}
63+
{row.bottomLinks.map((link, i) => (
64+
<path
65+
key={`b${i}`}
66+
d={linkPath(link.from, mid, link.to, ROW_H)}
67+
fill="none"
68+
stroke={link.color}
69+
strokeWidth={1.6}
70+
strokeLinecap="round"
71+
/>
72+
))}
73+
<circle cx={cx(row.col)} cy={mid} r={NODE_R} fill={row.color} stroke="var(--color-card)" strokeWidth={2} />
74+
</svg>
75+
)
76+
}
77+
78+
function CommitRow({ commit, row, gutter }: { commit: Commit, row: GraphRow, gutter: number }) {
79+
return (
80+
<li className="flex items-stretch" style={{ height: ROW_H }}>
81+
<GraphCell row={row} width={gutter} />
82+
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 pl-2">
83+
<div className="flex items-center gap-2">
84+
<span className="truncate text-sm font-medium">{commit.subject}</span>
85+
{commit.refs.map(ref => (
86+
<Badge key={ref} variant="outline" className="shrink-0 px-1.5 py-0 font-mono text-[10px]">{ref}</Badge>
87+
))}
88+
</div>
89+
<div className="text-muted-foreground flex items-center gap-2 text-xs">
90+
<code className="shrink-0">{commit.shortHash}</code>
91+
<span className="truncate">{commit.author}</span>
92+
<span aria-hidden>·</span>
93+
<span className="shrink-0" title={new Date(commit.date).toLocaleString()}>{relativeTime(commit.date)}</span>
94+
</div>
4595
</div>
4696
</li>
4797
)
@@ -55,6 +105,12 @@ export function LogPanel() {
55105
)
56106
const { data, loading, refresh } = useRpcResource(loader)
57107

108+
const graph = useMemo(
109+
() => computeGraph(data?.commits ?? []),
110+
[data?.commits],
111+
)
112+
const gutter = Math.max(graph.columns, 1) * COL_W + COL_W / 2
113+
58114
return (
59115
<div className="space-y-3">
60116
<div className="flex items-center justify-between">
@@ -81,9 +137,11 @@ export function LogPanel() {
81137
)}
82138

83139
{data?.isRepo && data.commits.length > 0 && (
84-
<ScrollArea className="h-80 pr-3">
140+
<ScrollArea className="h-96 pr-3">
85141
<ul>
86-
{data.commits.map(commit => <CommitRow key={commit.hash} commit={commit} />)}
142+
{data.commits.map((commit, i) => (
143+
<CommitRow key={commit.hash} commit={commit} row={graph.rows[i]} gutter={gutter} />
144+
))}
87145
</ul>
88146
</ScrollArea>
89147
)}

0 commit comments

Comments
 (0)