Skip to content

Commit 8d7f1af

Browse files
committed
reload button
1 parent 8191090 commit 8d7f1af

5 files changed

Lines changed: 122 additions & 7 deletions

File tree

app/api/git/commits/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,29 @@ export async function POST(request: NextRequest) {
3636
}
3737
})
3838

39+
// Detect uncommitted working directory changes and append a pseudo-commit at the end
40+
try {
41+
const status = execSync(`cd "${repoPath}" && git status --porcelain`, { encoding: "utf-8" })
42+
const hasChanges = status.trim().length > 0
43+
if (hasChanges && commits.length > 0) {
44+
const head = commits[0] // git log lists HEAD first
45+
commits.push({
46+
id: "WORKING_DIR",
47+
label: "WORK",
48+
hash: "WORKING_DIR",
49+
message: "Working directory (uncommitted changes)",
50+
author: "workspace",
51+
date: new Date().toISOString(),
52+
parents: [head.hash],
53+
// extra metadata for the client to style differently
54+
kind: "working",
55+
isWorkingDir: true,
56+
} as any)
57+
}
58+
} catch {
59+
// If status fails (e.g., not a git repo), ignore silently
60+
}
61+
3962
return NextResponse.json({ commits })
4063
} catch (error: any) {
4164
return NextResponse.json({ error: error.message || "Failed to fetch commits" }, { status: 500 })

app/api/git/diff/route.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,77 @@
11
import { execSync } from "child_process"
2+
import fs from "fs"
3+
import os from "os"
4+
import path from "path"
25
import { type NextRequest, NextResponse } from "next/server"
36

47
export async function POST(request: NextRequest) {
58
try {
6-
const { repoPath, commitHash } = await request.json()
9+
const { repoPath, commitHash } = await request.json()
710

811
if (!repoPath || !commitHash) {
912
return NextResponse.json({ error: "Repository path and commit hash are required" }, { status: 400 })
1013
}
1114

12-
// Use git show to get the patch for a single commit. Disable color and pager for clean parsing.
13-
const cmd = `cd "${repoPath}" && git show ${commitHash} --no-color --no-ext-diff`
14-
const diffText = execSync(cmd, { encoding: "utf-8", env: { ...process.env, GIT_PAGER: "" } })
15+
// Helper to run git commands that may exit with non-zero when differences exist (git diff returns 1)
16+
const runGit = (cmd: string): string => {
17+
try {
18+
return execSync(cmd, { encoding: "utf-8", env: { ...process.env, GIT_PAGER: "" } })
19+
} catch (e: any) {
20+
const out: Buffer | string | undefined = e?.stdout
21+
if (out) {
22+
return Buffer.isBuffer(out) ? out.toString("utf-8") : String(out)
23+
}
24+
throw e
25+
}
26+
}
27+
28+
// If the special working directory node is requested, return the working tree diff vs HEAD
29+
let diffText = ""
30+
if (commitHash === "WORKING_DIR") {
31+
// Base diff: tracked changes vs HEAD (staged + unstaged)
32+
const baseCmd = `cd "${repoPath}" && git diff HEAD --no-color --no-ext-diff`
33+
diffText = runGit(baseCmd)
34+
35+
// Append diffs for untracked files using no-index against an empty temp file
36+
const listCmd = `cd "${repoPath}" && git ls-files --others --exclude-standard -z`
37+
let untrackedRaw = ""
38+
try {
39+
// Capture as Buffer to safely handle NULs
40+
const buf: Buffer = execSync(listCmd, { env: { ...process.env, GIT_PAGER: "" } }) as unknown as Buffer
41+
untrackedRaw = buf.toString("utf-8")
42+
} catch {
43+
untrackedRaw = ""
44+
}
45+
const files = untrackedRaw
46+
.split("\u0000")
47+
.map((s) => s.trim())
48+
.filter((s) => s.length > 0)
49+
50+
if (files.length > 0) {
51+
// Create a temporary empty file
52+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vcv-"))
53+
const emptyPath = path.join(tmpDir, "empty")
54+
fs.writeFileSync(emptyPath, "")
55+
try {
56+
for (const f of files) {
57+
const perFileCmd = `cd "${repoPath}" && git diff --no-index --no-color --no-ext-diff -- "${emptyPath}" "${f}"`
58+
const perFile = runGit(perFileCmd)
59+
if (perFile && perFile.trim().length > 0) {
60+
if (diffText && !diffText.endsWith("\n")) diffText += "\n"
61+
diffText += perFile
62+
}
63+
}
64+
} finally {
65+
// Cleanup temp file and dir
66+
try { fs.unlinkSync(emptyPath) } catch {}
67+
try { fs.rmdirSync(tmpDir) } catch {}
68+
}
69+
}
70+
} else {
71+
// Use git show to get the patch for a single commit. Disable color and pager for clean parsing.
72+
const cmd = `cd "${repoPath}" && git show ${commitHash} --no-color --no-ext-diff`
73+
diffText = runGit(cmd)
74+
}
1575

1676
return NextResponse.json({ diff: diffText })
1777
} catch (error: any) {

app/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default function Page() {
1414
const [selectedCommit, setSelectedCommit] = useState<any>(null)
1515
const [diffOpen, setDiffOpen] = useState(false)
1616
const [isLoading, setIsLoading] = useState(false)
17+
const [diffRefreshKey, setDiffRefreshKey] = useState(0)
1718

1819
const fetchBranches = useCallback(async () => {
1920
try {
@@ -80,6 +81,7 @@ export default function Page() {
8081
branch={selectedBranch}
8182
onCommitSelect={(c) => setSelectedCommit(c)}
8283
isLoading={isLoading}
84+
onReloadDiffs={() => setDiffRefreshKey((k) => k + 1)}
8385
/>
8486
) : (
8587
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
@@ -99,6 +101,7 @@ export default function Page() {
99101
repoPath={selectedRepo}
100102
branch={selectedBranch}
101103
commit={selectedCommit}
104+
refreshKey={diffRefreshKey}
102105
/>
103106
</div>
104107
)

components/diff-dialog.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { html as renderDiff, parse as parseDiff } from "diff2html"
55
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
66
import { Badge } from "@/components/ui/badge"
77
import { Separator } from "@/components/ui/separator"
8-
import { Clock, GitCommit } from "lucide-react"
8+
import { Clock, GitCommit, RotateCcw } from "lucide-react"
99
import { format } from "date-fns"
1010
import { Button } from "@/components/ui/button"
1111
import { ButtonGroup } from "@/components/ui/button-group"
@@ -44,6 +44,7 @@ export function DiffDialog({
4444
>(new Map())
4545

4646
const [view, setView] = useState<"unified" | "split">("unified")
47+
const [refreshTick, setRefreshTick] = useState<number>(0)
4748

4849
const title = useMemo(() => {
4950
if (!commit) return "Commit"
@@ -133,7 +134,7 @@ export function DiffDialog({
133134
}
134135
load()
135136
// eslint-disable-next-line react-hooks/exhaustive-deps
136-
}, [commit, repoPath, open, view, visibleCount])
137+
}, [commit, repoPath, open, view, visibleCount, refreshTick])
137138

138139
// Clear cache when switching branches as requested
139140
useEffect(() => {
@@ -201,6 +202,26 @@ export function DiffDialog({
201202
</DialogDescription>
202203
</div>
203204
<div className="flex items-center gap-2">
205+
{(() => {
206+
const isWorking = !!commit && (commit.isWorkingDir || commit.kind === "working" || commit.hash === "WORKING_DIR")
207+
if (!isWorking) return null
208+
const onReload = () => {
209+
if (!commit) return
210+
const cacheKey = `${repoPath}::${commit.hash}`
211+
diffCacheRef.current.delete(cacheKey)
212+
setDiffHtml("")
213+
setError(null)
214+
setVisibleCount(CHUNK_SIZE)
215+
// scroll to top for better UX
216+
try { scrollRef.current?.scrollTo?.({ top: 0 }) } catch {}
217+
setRefreshTick((t) => t + 1)
218+
}
219+
return (
220+
<Button size="sm" variant="outline" onClick={onReload} disabled={loading}>
221+
<RotateCcw className="h-4 w-4 mr-1" /> Reload
222+
</Button>
223+
)
224+
})()}
204225
<ButtonGroup>
205226
<Button
206227
size="sm"

components/git-visualizer.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface CommitNode {
2222
author: string
2323
date: string
2424
parents?: string[]
25+
kind?: string
26+
isWorkingDir?: boolean
2527
}
2628

2729
export function GitVisualizer({
@@ -63,7 +65,7 @@ export function GitVisualizer({
6365
const elements = useMemo(() => {
6466
if (!commits?.length) return []
6567
const ids = new Set(commits.map((c) => c.id))
66-
const nodes = commits.map((c) => ({ data: { id: c.id, label: c.label } }))
68+
const nodes = commits.map((c) => ({ data: { id: c.id, label: c.label, kind: (c as any).kind } }))
6769
const edges: any[] = []
6870
for (const child of commits) {
6971
for (const parent of child.parents || []) {
@@ -118,6 +120,12 @@ export function GitVisualizer({
118120
height: 16,
119121
},
120122
},
123+
{
124+
selector: 'node[kind = "working"]',
125+
style: {
126+
"background-color": "hsl(24, 94%, 50%)", // orange for working directory
127+
},
128+
},
121129
{
122130
selector: "edge",
123131
style: {

0 commit comments

Comments
 (0)