feat: add git integration with file browsing and diff viewing

Add comprehensive git support across the stack:
- CLI: register git RPC handlers (status, diff numstat, diff file)
- Server: create git routes with proper session path resolution
- Web: add file browser, diff viewer, and git status visualization
- Add FileIcon component and git parser utilities
- Add TanStack Router routes for /files and /file pages
- Add git-themed CSS variables for light and dark modes
This commit is contained in:
weishu
2025-12-20 23:00:26 +08:00
parent 17eeba10d6
commit 8ebd4f6ab9
18 changed files with 1667 additions and 3 deletions
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useParams, useSearch } from '@tanstack/react-router'
import type { GitCommandResponse } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { queryKeys } from '@/lib/query-keys'
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
function decodeBase64(value: string): { text: string; ok: boolean } {
try {
return { text: atob(value), ok: true }
} catch {
try {
return { text: decodeURIComponent(escape(atob(value))), ok: true }
} catch {
return { text: '', ok: false }
}
}
}
function decodePath(value: string): string {
if (!value) return ''
const decoded = decodeBase64(value)
return decoded.ok ? decoded.text : value
}
function BackIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function DiffDisplay(props: { diffContent: string }) {
const lines = props.diffContent.split('\n')
return (
<div className="overflow-hidden rounded-md border border-[var(--app-border)] bg-[var(--app-bg)]">
{lines.map((line, index) => {
const isAdd = line.startsWith('+') && !line.startsWith('+++')
const isRemove = line.startsWith('-') && !line.startsWith('---')
const isHunk = line.startsWith('@@')
const isHeader = line.startsWith('+++') || line.startsWith('---')
const className = [
'whitespace-pre-wrap px-3 py-0.5 text-xs font-mono',
isAdd ? 'bg-[var(--app-diff-added-bg)] text-[var(--app-diff-added-text)]' : '',
isRemove ? 'bg-[var(--app-diff-removed-bg)] text-[var(--app-diff-removed-text)]' : '',
isHunk ? 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)] font-semibold' : '',
isHeader ? 'text-[var(--app-hint)] font-semibold' : ''
].filter(Boolean).join(' ')
const style = isAdd
? { borderLeft: '2px solid var(--app-git-staged-color)' }
: isRemove
? { borderLeft: '2px solid var(--app-git-deleted-color)' }
: undefined
return (
<div key={`${index}-${line}`} className={className} style={style}>
{line || ' '}
</div>
)
})}
</div>
)
}
function resolveLanguage(path: string): string | undefined {
const parts = path.split('.')
if (parts.length <= 1) return undefined
const ext = parts[parts.length - 1]?.toLowerCase()
if (!ext) return undefined
return langAlias[ext] ?? ext
}
function isBinaryContent(content: string): boolean {
if (!content) return false
if (content.includes('\0')) return true
const nonPrintable = content.split('').filter((char) => {
const code = char.charCodeAt(0)
return code < 32 && code !== 9 && code !== 10 && code !== 13
}).length
return nonPrintable / content.length > 0.1
}
function extractCommandError(result: GitCommandResponse | undefined): string | null {
if (!result) return null
if (result.success) return null
return result.error ?? result.stderr ?? 'Failed to load diff'
}
export default function FilePage() {
const { api } = useAppContext()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId/file' })
const search = useSearch({ from: '/sessions/$sessionId/file' })
const encodedPath = typeof search.path === 'string' ? search.path : ''
const staged = search.staged
const filePath = useMemo(() => decodePath(encodedPath), [encodedPath])
const fileName = filePath.split('/').pop() || filePath || 'File'
const diffQuery = useQuery({
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
queryFn: async () => {
if (!api || !sessionId || !filePath) {
throw new Error('Missing session or path')
}
return await api.getGitDiffFile(sessionId, filePath, staged)
},
enabled: Boolean(api && sessionId && filePath)
})
const fileQuery = useQuery({
queryKey: queryKeys.sessionFile(sessionId, filePath),
queryFn: async () => {
if (!api || !sessionId || !filePath) {
throw new Error('Missing session or path')
}
return await api.readSessionFile(sessionId, filePath)
},
enabled: Boolean(api && sessionId && filePath)
})
const diffContent = diffQuery.data?.success ? (diffQuery.data.stdout ?? '') : ''
const diffError = extractCommandError(diffQuery.data)
const diffSuccess = diffQuery.data?.success === true
const diffFailed = diffQuery.data?.success === false
const fileContentResult = fileQuery.data
const decodedContentResult = fileContentResult?.success && fileContentResult.content
? decodeBase64(fileContentResult.content)
: { text: '', ok: true }
const decodedContent = decodedContentResult.text
const binaryFile = fileContentResult?.success
? !decodedContentResult.ok || isBinaryContent(decodedContent)
: false
const language = useMemo(() => resolveLanguage(filePath), [filePath])
const highlighted = useShikiHighlighter(decodedContent, language)
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
useEffect(() => {
if (diffSuccess && !diffContent) {
setDisplayMode('file')
return
}
if (diffFailed) {
setDisplayMode('file')
}
}, [diffSuccess, diffFailed, diffContent])
const loading = diffQuery.isLoading || fileQuery.isLoading
const fileError = fileContentResult && !fileContentResult.success
? (fileContentResult.error ?? 'Failed to read file')
: null
const missingPath = !filePath
const diffErrorMessage = diffError ? `Diff unavailable: ${diffError}` : null
return (
<div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] border-b border-[var(--app-border)]">
<div className="mx-auto w-full max-w-[720px] flex items-center gap-2 p-3">
<button
type="button"
onClick={goBack}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<BackIcon />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">{fileName}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{filePath || 'Unknown path'}</div>
</div>
</div>
</div>
<div className="border-b border-[var(--app-divider)] bg-[var(--app-bg)] px-3 py-2 flex items-center gap-2">
<FileIcon fileName={fileName} size={20} />
<span className="text-xs text-[var(--app-hint)]">{filePath}</span>
</div>
{diffContent ? (
<div className="border-b border-[var(--app-divider)] bg-[var(--app-bg)] px-3 py-2 flex items-center gap-2">
<button
type="button"
onClick={() => setDisplayMode('diff')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'diff' ? 'bg-[var(--app-link)] text-white' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
Diff
</button>
<button
type="button"
onClick={() => setDisplayMode('file')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'file' ? 'bg-[var(--app-link)] text-white' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
File
</button>
</div>
) : null}
<div className="flex-1 overflow-y-auto p-4">
{diffErrorMessage ? (
<div className="mb-3 rounded-md bg-amber-500/10 p-2 text-xs text-[var(--app-hint)]">
{diffErrorMessage}
</div>
) : null}
{missingPath ? (
<div className="text-sm text-[var(--app-hint)]">No file path provided.</div>
) : loading ? (
<div className="text-sm text-[var(--app-hint)]">Loading file...</div>
) : fileError ? (
<div className="text-sm text-[var(--app-hint)]">{fileError}</div>
) : binaryFile ? (
<div className="text-sm text-[var(--app-hint)]">
This looks like a binary file. It cannot be displayed.
</div>
) : displayMode === 'diff' && diffContent ? (
<DiffDisplay diffContent={diffContent} />
) : displayMode === 'diff' && diffError ? (
<div className="text-sm text-[var(--app-hint)]">{diffError}</div>
) : displayMode === 'file' ? (
decodedContent ? (
<pre className="shiki overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 text-xs font-mono">
<code>{highlighted ?? decodedContent}</code>
</pre>
) : (
<div className="text-sm text-[var(--app-hint)]">File is empty.</div>
)
) : (
<div className="text-sm text-[var(--app-hint)]">No changes to display.</div>
)}
</div>
</div>
)
}
+375
View File
@@ -0,0 +1,375 @@
import { useCallback, useMemo, useState } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import type { FileSearchItem, GitFileStatus } from '@/types/api'
import { FileIcon } from '@/components/FileIcon'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles'
import { useSession } from '@/hooks/queries/useSession'
import { useSessionFileSearch } from '@/hooks/queries/useSessionFileSearch'
function encodePath(value: string): string {
try {
return btoa(value)
} catch {
return btoa(unescape(encodeURIComponent(value)))
}
}
function BackIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function RefreshIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M21 12a9 9 0 1 1-3-6.7" />
<polyline points="21 3 21 9 15 9" />
</svg>
)
}
function SearchIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
function GitBranchIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="6" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
)
}
function FolderIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
)
}
function StatusBadge(props: { status: GitFileStatus['status'] }) {
const { label, color } = useMemo(() => {
switch (props.status) {
case 'added':
return { label: 'A', color: 'var(--app-git-staged-color)' }
case 'deleted':
return { label: 'D', color: 'var(--app-git-deleted-color)' }
case 'renamed':
return { label: 'R', color: 'var(--app-git-renamed-color)' }
case 'untracked':
return { label: '?', color: 'var(--app-git-untracked-color)' }
case 'conflicted':
return { label: 'U', color: 'var(--app-git-deleted-color)' }
default:
return { label: 'M', color: 'var(--app-git-unstaged-color)' }
}
}, [props.status])
return (
<span
className="inline-flex items-center justify-center rounded border px-1.5 py-0.5 text-[10px] font-semibold"
style={{ color, borderColor: color }}
>
{label}
</span>
)
}
function LineChanges(props: { added: number; removed: number }) {
if (!props.added && !props.removed) return null
return (
<span className="flex items-center gap-1 text-[11px] font-mono">
{props.added ? (
<span className="text-[var(--app-diff-added-text)]">+{props.added}</span>
) : null}
{props.removed ? (
<span className="text-[var(--app-diff-removed-text)]">-{props.removed}</span>
) : null}
</span>
)
}
function GitFileRow(props: {
file: GitFileStatus
onOpen: () => void
showDivider: boolean
}) {
const subtitle = props.file.filePath || 'project root'
return (
<button
type="button"
onClick={props.onOpen}
className={`flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-[var(--app-subtle-bg)] transition-colors ${props.showDivider ? 'border-b border-[var(--app-divider)]' : ''}`}
>
<FileIcon fileName={props.file.fileName} size={22} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{props.file.fileName}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
</div>
<div className="flex items-center gap-2">
<LineChanges added={props.file.linesAdded} removed={props.file.linesRemoved} />
<StatusBadge status={props.file.status} />
</div>
</button>
)
}
function SearchResultRow(props: {
file: FileSearchItem
onOpen: () => void
showDivider: boolean
}) {
const subtitle = props.file.filePath || 'project root'
const icon = props.file.fileType === 'file'
? <FileIcon fileName={props.file.fileName} size={22} />
: <FolderIcon className="text-[var(--app-link)]" />
return (
<button
type="button"
onClick={props.onOpen}
className={`flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-[var(--app-subtle-bg)] transition-colors ${props.showDivider ? 'border-b border-[var(--app-divider)]' : ''}`}
>
{icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{props.file.fileName}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
</div>
</button>
)
}
export default function FilesPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const goBack = useAppGoBack()
const { sessionId } = useParams({ from: '/sessions/$sessionId/files' })
const { session } = useSession(api, sessionId)
const [searchQuery, setSearchQuery] = useState('')
const {
status: gitStatus,
error: gitError,
isLoading: gitLoading,
refetch: refetchGit
} = useGitStatusFiles(api, sessionId)
const shouldSearch = Boolean(searchQuery)
|| (gitStatus ? (gitStatus.totalStaged === 0 && gitStatus.totalUnstaged === 0) : Boolean(gitError))
const searchResults = useSessionFileSearch(api, sessionId, searchQuery, {
enabled: shouldSearch && !gitLoading
})
const handleOpenFile = useCallback((path: string, staged?: boolean) => {
const search = staged === undefined
? { path: encodePath(path) }
: { path: encodePath(path), staged }
navigate({
to: '/sessions/$sessionId/file',
params: { sessionId },
search
})
}, [navigate, sessionId])
const branchLabel = gitStatus?.branch ?? 'detached'
const subtitle = session?.metadata?.path ?? sessionId
const showGitErrorBanner = Boolean(gitError)
return (
<div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] border-b border-[var(--app-border)]">
<div className="mx-auto w-full max-w-[720px] flex items-center gap-2 p-3">
<button
type="button"
onClick={goBack}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<BackIcon />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">Files</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
</div>
<button
type="button"
onClick={() => { void refetchGit() }}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
title="Refresh"
>
<RefreshIcon />
</button>
</div>
</div>
<div className="border-b border-[var(--app-border)] bg-[var(--app-bg)] p-3">
<div className="flex items-center gap-2 rounded-md bg-[var(--app-subtle-bg)] px-3 py-2">
<SearchIcon className="text-[var(--app-hint)]" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search files"
className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none"
autoCapitalize="none"
autoCorrect="off"
/>
</div>
</div>
{!gitLoading && gitStatus ? (
<div className="border-b border-[var(--app-divider)] bg-[var(--app-bg)] px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<GitBranchIcon className="text-[var(--app-hint)]" />
<span className="font-semibold">{branchLabel}</span>
</div>
<div className="text-xs text-[var(--app-hint)]">
{gitStatus.totalStaged} staged, {gitStatus.totalUnstaged} unstaged
</div>
</div>
) : null}
<div className="flex-1 overflow-y-auto">
{showGitErrorBanner ? (
<div className="border-b border-[var(--app-divider)] bg-amber-500/10 px-3 py-2 text-xs text-[var(--app-hint)]">
{gitError}
</div>
) : null}
{gitLoading ? (
<div className="p-6 text-sm text-[var(--app-hint)]">Loading Git status...</div>
) : shouldSearch ? (
searchResults.isLoading ? (
<div className="p-6 text-sm text-[var(--app-hint)]">Loading files...</div>
) : searchResults.error ? (
<div className="p-6 text-sm text-[var(--app-hint)]">{searchResults.error}</div>
) : searchResults.files.length === 0 ? (
<div className="p-6 text-sm text-[var(--app-hint)]">
{searchQuery ? 'No files match your search.' : 'No files found in this project.'}
</div>
) : (
<div className="border-t border-[var(--app-divider)]">
{searchResults.files.map((file, index) => (
<SearchResultRow
key={`${file.fullPath}-${index}`}
file={file}
onOpen={() => handleOpenFile(file.fullPath)}
showDivider={index < searchResults.files.length - 1}
/>
))}
</div>
)
) : (
<div>
{gitStatus?.stagedFiles.length ? (
<div>
<div className="border-b border-[var(--app-divider)] bg-[var(--app-bg)] px-3 py-2 text-xs font-semibold text-[var(--app-git-staged-color)]">
Staged Changes ({gitStatus.stagedFiles.length})
</div>
{gitStatus.stagedFiles.map((file, index) => (
<GitFileRow
key={`staged-${file.fullPath}-${index}`}
file={file}
onOpen={() => handleOpenFile(file.fullPath, file.isStaged)}
showDivider={index < gitStatus.stagedFiles.length - 1 || gitStatus.unstagedFiles.length > 0}
/>
))}
</div>
) : null}
{gitStatus?.unstagedFiles.length ? (
<div>
<div className="border-b border-[var(--app-divider)] bg-[var(--app-bg)] px-3 py-2 text-xs font-semibold text-[var(--app-git-unstaged-color)]">
Unstaged Changes ({gitStatus.unstagedFiles.length})
</div>
{gitStatus.unstagedFiles.map((file, index) => (
<GitFileRow
key={`unstaged-${file.fullPath}-${index}`}
file={file}
onOpen={() => handleOpenFile(file.fullPath, file.isStaged)}
showDivider={index < gitStatus.unstagedFiles.length - 1}
/>
))}
</div>
) : null}
{gitStatus && gitStatus.stagedFiles.length === 0 && gitStatus.unstagedFiles.length === 0 ? (
<div className="p-6 text-sm text-[var(--app-hint)]">
No changes detected. Use search to browse files.
</div>
) : null}
</div>
)}
</div>
</div>
)
}