mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AuthResponse,
|
||||
FileReadResponse,
|
||||
FileSearchResponse,
|
||||
GitCommandResponse,
|
||||
MachinesResponse,
|
||||
MessagesResponse,
|
||||
SpawnResponse,
|
||||
@@ -71,6 +74,43 @@ export class ApiClient {
|
||||
return await this.request<MessagesResponse>(url)
|
||||
}
|
||||
|
||||
async getGitStatus(sessionId: string): Promise<GitCommandResponse> {
|
||||
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`)
|
||||
}
|
||||
|
||||
async getGitDiffNumstat(sessionId: string, staged: boolean): Promise<GitCommandResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('staged', staged ? 'true' : 'false')
|
||||
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-numstat?${params.toString()}`)
|
||||
}
|
||||
|
||||
async getGitDiffFile(sessionId: string, path: string, staged?: boolean): Promise<GitCommandResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('path', path)
|
||||
if (staged !== undefined) {
|
||||
params.set('staged', staged ? 'true' : 'false')
|
||||
}
|
||||
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-file?${params.toString()}`)
|
||||
}
|
||||
|
||||
async searchSessionFiles(sessionId: string, query: string, limit?: number): Promise<FileSearchResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (query) {
|
||||
params.set('query', query)
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
params.set('limit', `${limit}`)
|
||||
}
|
||||
const qs = params.toString()
|
||||
return await this.request<FileSearchResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
async readSessionFile(sessionId: string, path: string): Promise<FileReadResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('path', path)
|
||||
return await this.request<FileReadResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`)
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, text: string, localId?: string | null): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const EXTENSION_COLORS: Record<string, string> = {
|
||||
ts: '#3178c6',
|
||||
tsx: '#3178c6',
|
||||
js: '#f7df1e',
|
||||
jsx: '#f7df1e',
|
||||
json: '#f59e0b',
|
||||
md: '#64748b',
|
||||
mdx: '#64748b',
|
||||
css: '#2563eb',
|
||||
scss: '#db2777',
|
||||
html: '#f97316',
|
||||
yml: '#ef4444',
|
||||
yaml: '#ef4444',
|
||||
sh: '#10b981',
|
||||
bash: '#10b981',
|
||||
py: '#3776ab',
|
||||
go: '#0ea5e9',
|
||||
rs: '#f97316',
|
||||
}
|
||||
|
||||
function getFileExtension(fileName: string): string {
|
||||
const trimmed = fileName.trim()
|
||||
if (trimmed.startsWith('.') && trimmed.indexOf('.', 1) === -1) {
|
||||
return trimmed.slice(1).toLowerCase()
|
||||
}
|
||||
const parts = trimmed.split('.')
|
||||
if (parts.length <= 1) return ''
|
||||
return parts[parts.length - 1]?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
export function FileIcon(props: { fileName: string; size?: number }) {
|
||||
const size = props.size ?? 20
|
||||
const color = useMemo(() => {
|
||||
const ext = getFileExtension(props.fileName)
|
||||
return EXTENSION_COLORS[ext] ?? 'var(--app-hint)'
|
||||
}, [props.fileName])
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color }}
|
||||
>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
|
||||
@@ -30,6 +31,7 @@ export function SessionChat(props: {
|
||||
onRetryMessage?: (localId: string) => void
|
||||
}) {
|
||||
const { haptic } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const controlsDisabled = !props.session.active
|
||||
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
|
||||
@@ -106,6 +108,13 @@ export function SessionChat(props: {
|
||||
props.onRefresh()
|
||||
}, [abortSession, props.onRefresh])
|
||||
|
||||
const handleViewFiles = useCallback(() => {
|
||||
navigate({
|
||||
to: '/sessions/$sessionId/files',
|
||||
params: { sessionId: props.session.id }
|
||||
})
|
||||
}, [navigate, props.session.id])
|
||||
|
||||
const runtime = useHappyRuntime({
|
||||
session: props.session,
|
||||
blocks: reconciled.blocks,
|
||||
@@ -166,6 +175,7 @@ export function SessionChat(props: {
|
||||
<SessionHeader
|
||||
session={props.session}
|
||||
onBack={props.onBack}
|
||||
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
|
||||
/>
|
||||
|
||||
{controlsDisabled ? (
|
||||
|
||||
@@ -16,9 +16,30 @@ function getSessionTitle(session: Session): string {
|
||||
return session.id.slice(0, 8)
|
||||
}
|
||||
|
||||
function FilesIcon(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="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionHeader(props: {
|
||||
session: Session
|
||||
onBack: () => void
|
||||
onViewFiles?: () => void
|
||||
}) {
|
||||
const title = useMemo(() => getSessionTitle(props.session), [props.session])
|
||||
|
||||
@@ -60,6 +81,17 @@ export function SessionHeader(props: {
|
||||
{props.session.metadata?.path ?? props.session.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.onViewFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onViewFiles}
|
||||
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="Files"
|
||||
>
|
||||
<FilesIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { GitStatusFiles } from '@/types/api'
|
||||
import { buildGitStatusFiles } from '@/lib/gitParsers'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
export function useGitStatusFiles(api: ApiClient | null, sessionId: string | null): {
|
||||
status: GitStatusFiles | null
|
||||
error: string | null
|
||||
isLoading: boolean
|
||||
refetch: () => Promise<unknown>
|
||||
} {
|
||||
const resolvedSessionId = sessionId ?? 'unknown'
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.gitStatus(resolvedSessionId),
|
||||
queryFn: async () => {
|
||||
if (!api || !sessionId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
|
||||
const statusResult = await api.getGitStatus(sessionId)
|
||||
if (!statusResult.success) {
|
||||
return {
|
||||
status: null,
|
||||
error: statusResult.error ?? statusResult.stderr ?? 'Git status unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
const [unstagedResult, stagedResult] = await Promise.all([
|
||||
api.getGitDiffNumstat(sessionId, false),
|
||||
api.getGitDiffNumstat(sessionId, true)
|
||||
])
|
||||
|
||||
const status = buildGitStatusFiles(
|
||||
statusResult.stdout ?? '',
|
||||
unstagedResult.success ? (unstagedResult.stdout ?? '') : '',
|
||||
stagedResult.success ? (stagedResult.stdout ?? '') : ''
|
||||
)
|
||||
|
||||
const errors: string[] = []
|
||||
if (!unstagedResult.success) {
|
||||
errors.push(`Unstaged diff unavailable: ${unstagedResult.error ?? unstagedResult.stderr ?? 'unknown error'}`)
|
||||
}
|
||||
if (!stagedResult.success) {
|
||||
errors.push(`Staged diff unavailable: ${stagedResult.error ?? stagedResult.stderr ?? 'unknown error'}`)
|
||||
}
|
||||
|
||||
return { status, error: errors.length ? errors.join(' ') : null }
|
||||
},
|
||||
enabled: Boolean(api && sessionId),
|
||||
})
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
error: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
refetch: query.refetch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { FileSearchItem } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
export function useSessionFileSearch(
|
||||
api: ApiClient | null,
|
||||
sessionId: string | null,
|
||||
query: string,
|
||||
options?: { limit?: number; enabled?: boolean }
|
||||
): {
|
||||
files: FileSearchItem[]
|
||||
error: string | null
|
||||
isLoading: boolean
|
||||
refetch: () => Promise<unknown>
|
||||
} {
|
||||
const resolvedSessionId = sessionId ?? 'unknown'
|
||||
const limit = options?.limit ?? 200
|
||||
const enabled = options?.enabled ?? Boolean(api && sessionId)
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: queryKeys.sessionFiles(resolvedSessionId, query),
|
||||
queryFn: async () => {
|
||||
if (!api || !sessionId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
const response = await api.searchSessionFiles(sessionId, query, limit)
|
||||
if (!response.success) {
|
||||
return { files: [], error: response.error ?? 'Failed to search files' }
|
||||
}
|
||||
return { files: response.files ?? [], error: null }
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
files: result.data?.files ?? [],
|
||||
error: result.data?.error ?? null,
|
||||
isLoading: result.isLoading,
|
||||
refetch: result.refetch
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@
|
||||
--app-diff-removed-bg: #ffeef0;
|
||||
--app-diff-removed-text: #24292e;
|
||||
|
||||
/* Git status colors (light) */
|
||||
--app-git-staged-color: #34C759;
|
||||
--app-git-unstaged-color: #FF9500;
|
||||
--app-git-deleted-color: #FF3B30;
|
||||
--app-git-renamed-color: #2563eb;
|
||||
--app-git-untracked-color: #8E8E93;
|
||||
|
||||
/* Badge colors (light) */
|
||||
--app-badge-warning-bg: rgba(245, 158, 11, 0.2);
|
||||
--app-badge-warning-text: #b45309;
|
||||
@@ -58,6 +65,13 @@
|
||||
--app-diff-removed-bg: #3f1b23;
|
||||
--app-diff-removed-text: #c9d1d9;
|
||||
|
||||
/* Git status colors (dark) */
|
||||
--app-git-staged-color: #4ade80;
|
||||
--app-git-unstaged-color: #f59e0b;
|
||||
--app-git-deleted-color: #f87171;
|
||||
--app-git-renamed-color: #60a5fa;
|
||||
--app-git-untracked-color: #9ca3af;
|
||||
|
||||
/* Badge colors (dark) */
|
||||
--app-badge-warning-bg: rgba(251, 191, 36, 0.2);
|
||||
--app-badge-warning-text: #fbbf24;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { GitFileStatus, GitStatusFiles } from '@/types/api'
|
||||
|
||||
export type GitFileEntryV2 = {
|
||||
path: string
|
||||
index: string
|
||||
workingDir: string
|
||||
from?: string
|
||||
}
|
||||
|
||||
export type GitBranchInfo = {
|
||||
oid?: string
|
||||
head?: string
|
||||
upstream?: string
|
||||
ahead?: number
|
||||
behind?: number
|
||||
}
|
||||
|
||||
export type GitStatusSummaryV2 = {
|
||||
files: GitFileEntryV2[]
|
||||
notAdded: string[]
|
||||
ignored: string[]
|
||||
branch: GitBranchInfo
|
||||
}
|
||||
|
||||
export type DiffFileStat = {
|
||||
file: string
|
||||
changes: number
|
||||
insertions: number
|
||||
deletions: number
|
||||
binary: boolean
|
||||
}
|
||||
|
||||
export type DiffSummary = {
|
||||
files: DiffFileStat[]
|
||||
insertions: number
|
||||
deletions: number
|
||||
changes: number
|
||||
changed: number
|
||||
}
|
||||
|
||||
const BRANCH_OID_REGEX = /^# branch\.oid (.+)$/
|
||||
const BRANCH_HEAD_REGEX = /^# branch\.head (.+)$/
|
||||
const BRANCH_UPSTREAM_REGEX = /^# branch\.upstream (.+)$/
|
||||
const BRANCH_AB_REGEX = /^# branch\.ab \+(\d+) -(\d+)$/
|
||||
|
||||
const ORDINARY_CHANGE_REGEX = /^1 (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) (.+)$/
|
||||
const RENAME_COPY_REGEX = /^2 (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) ([RC])(\d{1,3}) (.+)\t(.+)$/
|
||||
const UNMERGED_REGEX = /^u (.)(.) (.{4}) (\d{6}) (\d{6}) (\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) ([0-9a-f]+) (.+)$/
|
||||
const UNTRACKED_REGEX = /^\? (.+)$/
|
||||
const IGNORED_REGEX = /^! (.+)$/
|
||||
|
||||
const NUMSTAT_REGEX = /^(\d+|-)\t(\d+|-)\t(.*)$/
|
||||
|
||||
export function parseStatusSummaryV2(statusOutput: string): GitStatusSummaryV2 {
|
||||
const lines = statusOutput.trim().split('\n').filter((line) => line.length > 0)
|
||||
|
||||
const result: GitStatusSummaryV2 = {
|
||||
files: [],
|
||||
notAdded: [],
|
||||
ignored: [],
|
||||
branch: {}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('# branch.oid ')) {
|
||||
const match = BRANCH_OID_REGEX.exec(line)
|
||||
if (match) result.branch.oid = match[1]
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# branch.head ')) {
|
||||
const match = BRANCH_HEAD_REGEX.exec(line)
|
||||
if (match) result.branch.head = match[1]
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# branch.upstream ')) {
|
||||
const match = BRANCH_UPSTREAM_REGEX.exec(line)
|
||||
if (match) result.branch.upstream = match[1]
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# branch.ab ')) {
|
||||
const match = BRANCH_AB_REGEX.exec(line)
|
||||
if (match) {
|
||||
result.branch.ahead = parseInt(match[1], 10)
|
||||
result.branch.behind = parseInt(match[2], 10)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('1 ')) {
|
||||
const match = ORDINARY_CHANGE_REGEX.exec(line)
|
||||
if (match) {
|
||||
const entry = parseOrdinaryChange(match)
|
||||
if (entry) result.files.push(entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('2 ')) {
|
||||
const match = RENAME_COPY_REGEX.exec(line)
|
||||
if (match) {
|
||||
const entry = parseRenameCopy(match)
|
||||
if (entry) result.files.push(entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('u ')) {
|
||||
const match = UNMERGED_REGEX.exec(line)
|
||||
if (match) {
|
||||
const entry = parseUnmerged(match)
|
||||
if (entry) result.files.push(entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('? ')) {
|
||||
const match = UNTRACKED_REGEX.exec(line)
|
||||
if (match) result.notAdded.push(match[1])
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('! ')) {
|
||||
const match = IGNORED_REGEX.exec(line)
|
||||
if (match) result.ignored.push(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseNumStat(numStatOutput: string): DiffSummary {
|
||||
const lines = numStatOutput.trim().split('\n').filter((line) => line.length > 0)
|
||||
|
||||
const result: DiffSummary = {
|
||||
files: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
changes: 0,
|
||||
changed: 0
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const match = NUMSTAT_REGEX.exec(line)
|
||||
if (!match) continue
|
||||
const insertionsStr = match[1]
|
||||
const deletionsStr = match[2]
|
||||
const file = match[3]
|
||||
|
||||
const isBinary = insertionsStr === '-' || deletionsStr === '-'
|
||||
const insertions = isBinary ? 0 : parseInt(insertionsStr, 10)
|
||||
const deletions = isBinary ? 0 : parseInt(deletionsStr, 10)
|
||||
const changes = insertions + deletions
|
||||
|
||||
result.files.push({
|
||||
file,
|
||||
changes,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: isBinary
|
||||
})
|
||||
result.insertions += insertions
|
||||
result.deletions += deletions
|
||||
result.changes += changes
|
||||
result.changed += 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function createDiffStatsMap(summary: DiffSummary): Record<string, { added: number; removed: number; binary: boolean }> {
|
||||
const stats: Record<string, { added: number; removed: number; binary: boolean }> = {}
|
||||
|
||||
for (const file of summary.files) {
|
||||
const paths = normalizeNumstatPath(file.file)
|
||||
const stat = {
|
||||
added: file.insertions,
|
||||
removed: file.deletions,
|
||||
binary: file.binary
|
||||
}
|
||||
stats[file.file] = stat
|
||||
if (paths.newPath && paths.newPath !== file.file) {
|
||||
stats[paths.newPath] = stat
|
||||
}
|
||||
if (paths.oldPath && paths.oldPath !== file.file && paths.oldPath !== paths.newPath) {
|
||||
stats[paths.oldPath] = stat
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
export function getCurrentBranchV2(summary: GitStatusSummaryV2): string | null {
|
||||
const head = summary.branch.head
|
||||
if (!head || head === '(detached)' || head === '(initial)') return null
|
||||
return head
|
||||
}
|
||||
|
||||
export function buildGitStatusFiles(
|
||||
statusOutput: string,
|
||||
unstagedDiffOutput: string,
|
||||
stagedDiffOutput: string
|
||||
): GitStatusFiles {
|
||||
const statusSummary = parseStatusSummaryV2(statusOutput)
|
||||
const branchName = getCurrentBranchV2(statusSummary)
|
||||
|
||||
const unstagedDiff = parseNumStat(unstagedDiffOutput)
|
||||
const stagedDiff = parseNumStat(stagedDiffOutput)
|
||||
const unstagedStats = createDiffStatsMap(unstagedDiff)
|
||||
const stagedStats = createDiffStatsMap(stagedDiff)
|
||||
|
||||
const stagedFiles: GitFileStatus[] = []
|
||||
const unstagedFiles: GitFileStatus[] = []
|
||||
|
||||
for (const file of statusSummary.files) {
|
||||
const parts = file.path.split('/')
|
||||
const fileName = parts[parts.length - 1] || file.path
|
||||
const filePath = parts.slice(0, -1).join('/')
|
||||
|
||||
if (file.index !== ' ' && file.index !== '.' && file.index !== '?') {
|
||||
const status = getFileStatus(file.index)
|
||||
const stats = stagedStats[file.path] ?? { added: 0, removed: 0, binary: false }
|
||||
stagedFiles.push({
|
||||
fileName,
|
||||
filePath,
|
||||
fullPath: file.path,
|
||||
status,
|
||||
isStaged: true,
|
||||
linesAdded: stats.added,
|
||||
linesRemoved: stats.removed,
|
||||
oldPath: file.from
|
||||
})
|
||||
}
|
||||
|
||||
if (file.workingDir !== ' ' && file.workingDir !== '.') {
|
||||
const status = getFileStatus(file.workingDir)
|
||||
const stats = unstagedStats[file.path] ?? { added: 0, removed: 0, binary: false }
|
||||
unstagedFiles.push({
|
||||
fileName,
|
||||
filePath,
|
||||
fullPath: file.path,
|
||||
status,
|
||||
isStaged: false,
|
||||
linesAdded: stats.added,
|
||||
linesRemoved: stats.removed,
|
||||
oldPath: file.from
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const untrackedPath of statusSummary.notAdded) {
|
||||
const cleanPath = untrackedPath.endsWith('/') ? untrackedPath.slice(0, -1) : untrackedPath
|
||||
const parts = cleanPath.split('/')
|
||||
const fileName = parts[parts.length - 1] || cleanPath
|
||||
const filePath = parts.slice(0, -1).join('/')
|
||||
|
||||
if (untrackedPath.endsWith('/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
unstagedFiles.push({
|
||||
fileName,
|
||||
filePath,
|
||||
fullPath: cleanPath,
|
||||
status: 'untracked',
|
||||
isStaged: false,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
stagedFiles,
|
||||
unstagedFiles,
|
||||
branch: branchName,
|
||||
totalStaged: stagedFiles.length,
|
||||
totalUnstaged: unstagedFiles.length
|
||||
}
|
||||
}
|
||||
|
||||
function parseOrdinaryChange(matches: string[]): GitFileEntryV2 | null {
|
||||
if (!matches[1] || !matches[2] || !matches[9]) return null
|
||||
return {
|
||||
index: matches[1],
|
||||
workingDir: matches[2],
|
||||
path: matches[9]
|
||||
}
|
||||
}
|
||||
|
||||
function parseRenameCopy(matches: string[]): GitFileEntryV2 | null {
|
||||
if (!matches[1] || !matches[2] || !matches[11] || !matches[12]) return null
|
||||
return {
|
||||
index: matches[1],
|
||||
workingDir: matches[2],
|
||||
from: matches[11],
|
||||
path: matches[12]
|
||||
}
|
||||
}
|
||||
|
||||
function parseUnmerged(matches: string[]): GitFileEntryV2 | null {
|
||||
if (!matches[1] || !matches[2] || !matches[11]) return null
|
||||
return {
|
||||
index: matches[1],
|
||||
workingDir: matches[2],
|
||||
path: matches[11]
|
||||
}
|
||||
}
|
||||
|
||||
function getFileStatus(statusChar: string): GitFileStatus['status'] {
|
||||
switch (statusChar) {
|
||||
case 'M':
|
||||
return 'modified'
|
||||
case 'A':
|
||||
return 'added'
|
||||
case 'D':
|
||||
return 'deleted'
|
||||
case 'R':
|
||||
case 'C':
|
||||
return 'renamed'
|
||||
case '?':
|
||||
return 'untracked'
|
||||
case 'U':
|
||||
return 'conflicted'
|
||||
default:
|
||||
return 'modified'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNumstatPath(rawPath: string): { newPath: string; oldPath?: string } {
|
||||
const trimmed = rawPath.trim()
|
||||
if (trimmed.includes('{') && trimmed.includes('=>') && trimmed.includes('}')) {
|
||||
const newPath = trimmed.replace(/\{([^{}]+?)\s*=>\s*([^{}]+?)\}/g, (_, oldPart: string, newPart: string) => newPart.trim())
|
||||
const oldPath = trimmed.replace(/\{([^{}]+?)\s*=>\s*([^{}]+?)\}/g, (_, oldPart: string) => oldPart.trim())
|
||||
return { newPath, oldPath }
|
||||
}
|
||||
|
||||
if (trimmed.includes('=>')) {
|
||||
const parts = trimmed.split(/\s*=>\s*/)
|
||||
const oldPath = parts[0]?.trim()
|
||||
const newPath = parts[parts.length - 1]?.trim()
|
||||
if (newPath) {
|
||||
return { newPath, oldPath }
|
||||
}
|
||||
}
|
||||
|
||||
return { newPath: trimmed }
|
||||
}
|
||||
@@ -3,4 +3,13 @@ export const queryKeys = {
|
||||
session: (sessionId: string) => ['session', sessionId] as const,
|
||||
messages: (sessionId: string) => ['messages', sessionId] as const,
|
||||
machines: ['machines'] as const,
|
||||
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
|
||||
sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const,
|
||||
sessionFile: (sessionId: string, path: string) => ['session-file', sessionId, path] as const,
|
||||
gitFileDiff: (sessionId: string, path: string, staged?: boolean) => [
|
||||
'git-file-diff',
|
||||
sessionId,
|
||||
path,
|
||||
staged ? 'staged' : 'unstaged'
|
||||
] as const,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useSession } from '@/hooks/queries/useSession'
|
||||
import { useSessions } from '@/hooks/queries/useSessions'
|
||||
import { useSendMessage } from '@/hooks/mutations/useSendMessage'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import FilesPage from '@/routes/sessions/files'
|
||||
import FilePage from '@/routes/sessions/file'
|
||||
|
||||
function SessionsPage() {
|
||||
const { api } = useAppContext()
|
||||
@@ -193,6 +195,26 @@ const sessionRoute = createRoute({
|
||||
component: SessionPage,
|
||||
})
|
||||
|
||||
const sessionFilesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/sessions/$sessionId/files',
|
||||
component: FilesPage,
|
||||
})
|
||||
|
||||
const sessionFileRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/sessions/$sessionId/file',
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
path: typeof search.path === 'string' ? search.path : '',
|
||||
staged: search.staged === true || search.staged === 'true'
|
||||
? true
|
||||
: search.staged === false || search.staged === 'false'
|
||||
? false
|
||||
: undefined
|
||||
}),
|
||||
component: FilePage,
|
||||
})
|
||||
|
||||
const machinesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/machines',
|
||||
@@ -209,6 +231,8 @@ export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
sessionsRoute,
|
||||
sessionRoute,
|
||||
sessionFilesRoute,
|
||||
sessionFileRoute,
|
||||
machinesRoute,
|
||||
spawnRoute,
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -124,6 +124,52 @@ export type SpawnResponse =
|
||||
| { type: 'success'; sessionId: string }
|
||||
| { type: 'error'; message: string }
|
||||
|
||||
export type GitCommandResponse = {
|
||||
success: boolean
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exitCode?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type FileSearchItem = {
|
||||
fileName: string
|
||||
filePath: string
|
||||
fullPath: string
|
||||
fileType: 'file' | 'folder'
|
||||
}
|
||||
|
||||
export type FileSearchResponse = {
|
||||
success: boolean
|
||||
files?: FileSearchItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type FileReadResponse = {
|
||||
success: boolean
|
||||
content?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type GitFileStatus = {
|
||||
fileName: string
|
||||
filePath: string
|
||||
fullPath: string
|
||||
status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'conflicted'
|
||||
isStaged: boolean
|
||||
linesAdded: number
|
||||
linesRemoved: number
|
||||
oldPath?: string
|
||||
}
|
||||
|
||||
export type GitStatusFiles = {
|
||||
stagedFiles: GitFileStatus[]
|
||||
unstagedFiles: GitFileStatus[]
|
||||
branch: string | null
|
||||
totalStaged: number
|
||||
totalUnstaged: number
|
||||
}
|
||||
|
||||
export type SyncEvent =
|
||||
| { type: 'session-added'; sessionId: string; data?: unknown }
|
||||
| { type: 'session-updated'; sessionId: string; data?: unknown }
|
||||
|
||||
Reference in New Issue
Block a user