feat(git): /git overview page and diff viewer
Add git module with repo status overview (/git) and per-repo detail page (/git/[repo]) featuring a color-coded diff viewer (+ green, - red). Reads repo paths from REPO_PATHS env var, calls ops-agent git_status and git_diff commands. Status badges: clean (green), dirty (orange), behind-origin (blue). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4821d29670
commit
9e08a7c31f
5 changed files with 513 additions and 0 deletions
141
app/git/[repo]/_components/diff-viewer.tsx
Normal file
141
app/git/[repo]/_components/diff-viewer.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
async function fetchDiff(repoPath: string): Promise<string> {
|
||||
const res = await fetch('/api/agent/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`agent ${res.status}: ${text}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('no response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(5).trim()) as { data?: string }
|
||||
if (parsed.data !== undefined) output += parsed.data
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function DiffLine({ line }: { line: string }) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) {
|
||||
return <span className="text-muted-foreground">{line}{'\n'}</span>
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return (
|
||||
<span className="bg-green-500/10 text-green-700 dark:text-green-400 block">
|
||||
{line}{'\n'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
return (
|
||||
<span className="bg-red-500/10 text-red-700 dark:text-red-400 block">
|
||||
{line}{'\n'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
return <span className="text-blue-600 dark:text-blue-400">{line}{'\n'}</span>
|
||||
}
|
||||
if (line.startsWith('diff ') || line.startsWith('index ')) {
|
||||
return <span className="text-muted-foreground font-semibold">{line}{'\n'}</span>
|
||||
}
|
||||
return <span>{line}{'\n'}</span>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
repoPath: string
|
||||
initialDiff: string
|
||||
initialError: string | null
|
||||
}
|
||||
|
||||
export default function DiffViewer({ repoPath, initialDiff, initialError }: Props) {
|
||||
const [diff, setDiff] = useState(initialDiff)
|
||||
const [error, setError] = useState(initialError)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const data = await fetchDiff(repoPath)
|
||||
setDiff(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [repoPath])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refresh, 30000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">git diff HEAD</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{refreshing && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">refreshing…</span>
|
||||
)}
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{diff.trim() === '' ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
No uncommitted changes
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed">
|
||||
{diff.split('\n').map((line, i) => (
|
||||
<DiffLine key={i} line={line} />
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
app/git/[repo]/page.tsx
Normal file
89
app/git/[repo]/page.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { execAgent } from '@/lib/agent-client'
|
||||
import { parseGitStatus } from '@/lib/parse-git'
|
||||
import DiffViewer from './_components/diff-viewer'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ repo: string }>
|
||||
}
|
||||
|
||||
function findRepoPath(repoName: string): string | null {
|
||||
const paths = (process.env.REPO_PATHS ?? '')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
return paths.find((p) => p.split('/').filter(Boolean).pop() === repoName) ?? null
|
||||
}
|
||||
|
||||
export default async function GitRepoPage({ params }: Props) {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { repo } = await params
|
||||
const repoName = decodeURIComponent(repo)
|
||||
const repoPath = findRepoPath(repoName)
|
||||
|
||||
if (!repoPath) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Repositories
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Repo "{repoName}" not found in REPO_PATHS.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let statusSummary = ''
|
||||
let initialDiff = ''
|
||||
let initialError: string | null = null
|
||||
|
||||
try {
|
||||
const [statusOut, diffOut] = await Promise.all([
|
||||
execAgent('git_status', [repoPath]),
|
||||
execAgent('git_diff', [repoPath]),
|
||||
])
|
||||
const status = parseGitStatus(statusOut)
|
||||
statusSummary = `${status.branch}${status.dirty ? ' · dirty' : ' · clean'}${status.ahead ? ` · ↑${status.ahead} ahead` : ''}${status.behind ? ` · ↓${status.behind} behind` : ''}`
|
||||
initialDiff = diffOut
|
||||
} catch (err) {
|
||||
initialError = err instanceof Error ? err.message : 'Failed to fetch repo data'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Repositories
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-2xl font-semibold tracking-tight font-mono">{repoName}</h1>
|
||||
</div>
|
||||
|
||||
{statusSummary && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground font-mono">
|
||||
<span>{repoPath}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{statusSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-semibold mb-3">Uncommitted changes</h2>
|
||||
<DiffViewer repoPath={repoPath} initialDiff={initialDiff} initialError={initialError} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
app/git/_components/git-repos-list.tsx
Normal file
183
app/git/_components/git-repos-list.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { type RepoStatus, parseGitStatus } from '@/lib/parse-git'
|
||||
|
||||
interface RepoEntry {
|
||||
path: string
|
||||
name: string
|
||||
status: RepoStatus | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function fetchRepoStatus(repoPath: string): Promise<RepoStatus> {
|
||||
const res = await fetch('/api/agent/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`agent ${res.status}: ${text}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('no response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(5).trim()) as { data?: string }
|
||||
if (parsed.data !== undefined) output += parsed.data
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parseGitStatus(output)
|
||||
}
|
||||
|
||||
function statusBadge(status: RepoStatus) {
|
||||
if (status.dirty) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
<span className="size-1.5 rounded-full bg-orange-500 dark:bg-orange-400" />
|
||||
dirty
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status.behind && status.behind > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
<span className="size-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
{status.behind} behind
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
<span className="size-1.5 rounded-full bg-green-500 dark:bg-green-400" />
|
||||
clean
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialRepos: RepoEntry[]
|
||||
}
|
||||
|
||||
export default function GitReposList({ initialRepos }: Props) {
|
||||
const [repos, setRepos] = useState<RepoEntry[]>(initialRepos)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const updated = await Promise.all(
|
||||
initialRepos.map(async (r) => {
|
||||
try {
|
||||
const status = await fetchRepoStatus(r.path)
|
||||
return { ...r, status, error: null }
|
||||
} catch (err) {
|
||||
return { ...r, status: null, error: err instanceof Error ? err.message : 'failed' }
|
||||
}
|
||||
}),
|
||||
)
|
||||
setRepos(updated)
|
||||
setLastUpdated(new Date())
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [initialRepos])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refresh, 30000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{repos.length} repo{repos.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{refreshing && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">refreshing…</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Repo</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Branch</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Ahead</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map((r) => (
|
||||
<tr
|
||||
key={r.path}
|
||||
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link
|
||||
href={`/git/${encodeURIComponent(r.name)}`}
|
||||
className="hover:underline text-foreground"
|
||||
>
|
||||
{r.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{r.status?.branch ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.error ? (
|
||||
<span className="text-xs text-destructive">{r.error}</span>
|
||||
) : r.status ? (
|
||||
statusBadge(r.status)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{r.status?.ahead !== undefined && r.status.ahead > 0 ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">↑{r.status.ahead}</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{r.path}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
app/git/page.tsx
Normal file
54
app/git/page.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { execAgent } from '@/lib/agent-client'
|
||||
import { parseGitStatus, type RepoStatus } from '@/lib/parse-git'
|
||||
import GitReposList from './_components/git-repos-list'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function repoName(repoPath: string): string {
|
||||
return repoPath.split('/').filter(Boolean).pop() ?? repoPath
|
||||
}
|
||||
|
||||
export default async function GitPage() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const repoPaths = (process.env.REPO_PATHS ?? '')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const initialRepos = await Promise.all(
|
||||
repoPaths.map(async (path) => {
|
||||
let status: RepoStatus | null = null
|
||||
let error: string | null = null
|
||||
try {
|
||||
const output = await execAgent('git_status', [path])
|
||||
status = parseGitStatus(output)
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'failed'
|
||||
}
|
||||
return { path, name: repoName(path), status, error }
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Git Repositories</h1>
|
||||
<p className="text-sm text-muted-foreground">Auto-refreshes every 30 seconds</p>
|
||||
</div>
|
||||
{repoPaths.length === 0 ? (
|
||||
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
|
||||
No repos configured. Set <code className="font-mono">REPO_PATHS</code> in your
|
||||
environment (comma-separated absolute paths).
|
||||
</div>
|
||||
) : (
|
||||
<GitReposList initialRepos={initialRepos} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue