fix(web): 完善 Files 页面 i18n (#607)

* fix(web): 补全 Files 页面 i18n

* fix(web): 修正 Files git 错误聚合翻译
This commit is contained in:
junes
2026-05-10 11:36:14 +08:00
committed by GitHub
parent 3a6574f2b9
commit d8f3083c75
7 changed files with 307 additions and 38 deletions
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import { FileIcon } from '@/components/FileIcon'
import { useSessionDirectory } from '@/hooks/queries/useSessionDirectory'
import { formatDirectoryError } from '@/lib/files-i18n'
import { useTranslation } from '@/lib/use-translation'
function ChevronIcon(props: { className?: string; collapsed: boolean }) {
return (
@@ -83,6 +85,7 @@ function DirectoryNode(props: {
expanded: Set<string>
onToggle: (path: string) => void
}) {
const { t } = useTranslation()
const isExpanded = props.expanded.has(props.path)
const { entries, error, isLoading } = useSessionDirectory(props.api, props.sessionId, props.path, {
enabled: isExpanded
@@ -114,7 +117,7 @@ function DirectoryNode(props: {
isLoading ? (
<DirectorySkeleton depth={childDepth} />
) : error ? (
<DirectoryErrorRow depth={childDepth} message={error} />
<DirectoryErrorRow depth={childDepth} message={formatDirectoryError(error, t)} />
) : (
<div>
{directories.map((entry) => {
@@ -158,7 +161,7 @@ function DirectoryNode(props: {
className="px-3 py-2 text-sm text-[var(--app-hint)]"
style={{ paddingLeft: childIndent }}
>
Empty directory.
{t('files.directories.empty')}
</div>
) : null}
</div>
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
formatDiffError,
formatDirectoryError,
formatFileSearchError,
formatGitStatusError,
formatReadFileError,
getDetachedBranchLabel,
getProjectRootLabel,
} from './files-i18n'
const translations: Record<string, string> = {
'files.projectRoot': '项目根目录',
'files.branch.detached': '游离 HEAD',
'files.search.error.failed': '搜索文件失败',
'files.search.error.failedWithDetail': '搜索文件失败:{error}',
'files.directories.error.listFailed': '加载目录失败',
'files.directories.error.listFailedWithDetail': '加载目录失败:{error}',
'files.changes.error.gitStatusUnavailable': 'Git 状态不可用',
'files.changes.error.gitStatusUnavailableWithDetail': 'Git 状态不可用:{error}',
'files.changes.error.unstagedDiffUnavailableWithDetail': '未暂存 Diff 不可用:{error}',
'files.changes.error.stagedDiffUnavailableWithDetail': '已暂存 Diff 不可用:{error}',
'file.error.readFailed': '读取文件失败',
'file.error.readFailedWithDetail': '读取文件失败:{error}',
'file.error.diffUnavailable': 'Diff 不可用',
'file.error.diffUnavailableWithDetail': 'Diff 不可用:{error}',
}
function t(key: string, params?: Record<string, string | number>): string {
const template = translations[key] ?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`))
}
describe('files i18n helpers', () => {
it('localizes common file tree labels', () => {
expect(getProjectRootLabel('', t)).toBe('项目根目录')
expect(getProjectRootLabel('src/routes', t)).toBe('src/routes')
expect(getDetachedBranchLabel('detached', t)).toBe('游离 HEAD')
expect(getDetachedBranchLabel('main', t)).toBe('main')
})
it('formats git status errors with translated summaries', () => {
expect(formatGitStatusError('Git status unavailable', t)).toBe('Git 状态不可用')
expect(formatGitStatusError('Unstaged diff unavailable: fatal', t)).toBe('未暂存 Diff 不可用:fatal')
expect(formatGitStatusError('Staged diff unavailable: timeout', t)).toBe('已暂存 Diff 不可用:timeout')
expect(
formatGitStatusError(
'Unstaged diff unavailable: fatal Staged diff unavailable: timeout',
t
)
).toBe('未暂存 Diff 不可用:fatal 已暂存 Diff 不可用:timeout')
expect(formatGitStatusError('fatal: not a git repository', t)).toBe('Git 状态不可用:fatal: not a git repository')
})
it('formats search and directory errors', () => {
expect(formatFileSearchError('Failed to search files', t)).toBe('搜索文件失败')
expect(formatFileSearchError('ripgrep exited 2', t)).toBe('搜索文件失败:ripgrep exited 2')
expect(formatDirectoryError('Failed to list directory', t)).toBe('加载目录失败')
expect(formatDirectoryError('permission denied', t)).toBe('加载目录失败:permission denied')
})
it('formats file read and diff errors', () => {
expect(formatReadFileError('Failed to read file', t)).toBe('读取文件失败')
expect(formatReadFileError('EACCES', t)).toBe('读取文件失败:EACCES')
expect(formatDiffError('Failed to load diff', t)).toBe('Diff 不可用')
expect(formatDiffError('fatal: bad revision', t)).toBe('Diff 不可用:fatal: bad revision')
})
})
+90
View File
@@ -0,0 +1,90 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
function normalizeDetail(detail: string | null | undefined): string | null {
if (typeof detail !== 'string') return null
const trimmed = detail.trim()
return trimmed.length > 0 ? trimmed : null
}
function stripPrefix(value: string, prefix: string): string | null {
if (!value.startsWith(prefix)) return null
return normalizeDetail(value.slice(prefix.length))
}
function formatGitStatusErrorSegment(segment: string, t: Translate): string {
const unstagedDetail = stripPrefix(segment, 'Unstaged diff unavailable: ')
if (unstagedDetail) {
return t('files.changes.error.unstagedDiffUnavailableWithDetail', { error: unstagedDetail })
}
const stagedDetail = stripPrefix(segment, 'Staged diff unavailable: ')
if (stagedDetail) {
return t('files.changes.error.stagedDiffUnavailableWithDetail', { error: stagedDetail })
}
return t('files.changes.error.gitStatusUnavailableWithDetail', { error: segment })
}
export function getProjectRootLabel(path: string | null | undefined, t: Translate): string {
return normalizeDetail(path) ?? t('files.projectRoot')
}
export function getDetachedBranchLabel(branch: string | null | undefined, t: Translate): string {
const value = normalizeDetail(branch)
if (!value || value === 'detached') {
return t('files.branch.detached')
}
return value
}
export function formatFileSearchError(error: string | null | undefined, t: Translate): string {
const detail = normalizeDetail(error)
if (!detail || detail === 'Failed to search files' || detail === 'Session unavailable') {
return t('files.search.error.failed')
}
return t('files.search.error.failedWithDetail', { error: detail })
}
export function formatDirectoryError(error: string | null | undefined, t: Translate): string {
const detail = normalizeDetail(error)
if (!detail || detail === 'Failed to list directory' || detail === 'Session unavailable') {
return t('files.directories.error.listFailed')
}
return t('files.directories.error.listFailedWithDetail', { error: detail })
}
export function formatGitStatusError(error: string | null | undefined, t: Translate): string {
const detail = normalizeDetail(error)
if (!detail || detail === 'Git status unavailable' || detail === 'Session unavailable') {
return t('files.changes.error.gitStatusUnavailable')
}
const segments = detail
.split(/(?=Unstaged diff unavailable: |Staged diff unavailable: )/)
.map((segment) => normalizeDetail(segment))
.filter((segment): segment is string => Boolean(segment))
if (segments.length > 1) {
return segments
.map((segment) => formatGitStatusErrorSegment(segment, t))
.join(' ')
}
return formatGitStatusErrorSegment(detail, t)
}
export function formatReadFileError(error: string | null | undefined, t: Translate): string {
const detail = normalizeDetail(error)
if (!detail || detail === 'Failed to read file' || detail === 'Missing session or path' || detail === 'Session unavailable') {
return t('file.error.readFailed')
}
return t('file.error.readFailedWithDetail', { error: detail })
}
export function formatDiffError(error: string | null | undefined, t: Translate): string {
const detail = normalizeDetail(error)
if (!detail || detail === 'Failed to load diff' || detail === 'Missing session or path' || detail === 'Session unavailable') {
return t('file.error.diffUnavailable')
}
return t('file.error.diffUnavailableWithDetail', { error: detail })
}
+41
View File
@@ -4,6 +4,7 @@ export default {
'authorizing': 'Authorizing…',
'loading.session': 'Loading session…',
'loading.git': 'Loading Git status…',
'loading.file': 'Loading file…',
'loading.files': 'Loading files…',
'loading.messages': 'Loading messages…',
'loading.machines': 'Loading machines…',
@@ -180,6 +181,46 @@ export default {
'diff.title': 'Diff',
'diff.view': 'View',
// Files page
'files.page.title': 'Files',
'files.page.refresh': 'Refresh',
'files.page.searchPlaceholder': 'Search files',
'files.projectRoot': 'project root',
'files.branch.detached': 'detached HEAD',
'files.branch.summary': '{staged} staged, {unstaged} unstaged',
'files.tab.changes': 'Changes',
'files.tab.directories': 'Directories',
'files.search.empty': 'No files match your search.',
'files.search.error.failed': 'Failed to search files.',
'files.search.error.failedWithDetail': 'Failed to search files: {error}',
'files.changes.section.staged': 'Staged Changes ({n})',
'files.changes.section.unstaged': 'Unstaged Changes ({n})',
'files.changes.empty.unavailable': 'Git status unavailable. Use Directories to browse all files, or search.',
'files.changes.empty.none': 'No changes detected. Use Directories to browse all files, or search.',
'files.changes.error.gitStatusUnavailable': 'Git status unavailable.',
'files.changes.error.gitStatusUnavailableWithDetail': 'Git status unavailable: {error}',
'files.changes.error.unstagedDiffUnavailableWithDetail': 'Unstaged diff unavailable: {error}',
'files.changes.error.stagedDiffUnavailableWithDetail': 'Staged diff unavailable: {error}',
'files.directories.empty': 'Empty directory.',
'files.directories.error.listFailed': 'Failed to list directory.',
'files.directories.error.listFailedWithDetail': 'Failed to list directory: {error}',
// File page
'file.page.fallbackName': 'File',
'file.page.unknownPath': 'Unknown path',
'file.page.copyPath': 'Copy path',
'file.page.copyContent': 'Copy file content',
'file.page.tab.diff': 'Diff',
'file.page.tab.file': 'File',
'file.page.missingPath': 'No file path provided.',
'file.page.binary': 'This looks like a binary file. It cannot be displayed.',
'file.page.empty': 'File is empty.',
'file.page.noChanges': 'No changes to display.',
'file.error.readFailed': 'Failed to read file.',
'file.error.readFailedWithDetail': 'Failed to read file: {error}',
'file.error.diffUnavailable': 'Diff unavailable.',
'file.error.diffUnavailableWithDetail': 'Diff unavailable: {error}',
// Tool card
'tool.askQuestion': 'Other',
'tool.edit': 'Edit',
+41
View File
@@ -4,6 +4,7 @@ export default {
'authorizing': '认证中…',
'loading.session': '加载会话…',
'loading.git': '加载 Git 状态…',
'loading.file': '加载文件…',
'loading.files': '加载文件…',
'loading.messages': '加载消息…',
'loading.machines': '加载机器…',
@@ -182,6 +183,46 @@ export default {
'diff.title': '差异',
'diff.view': '查看',
// Files page
'files.page.title': '文件',
'files.page.refresh': '刷新',
'files.page.searchPlaceholder': '搜索文件',
'files.projectRoot': '项目根目录',
'files.branch.detached': '游离 HEAD',
'files.branch.summary': '{staged} 个已暂存,{unstaged} 个未暂存',
'files.tab.changes': '变更',
'files.tab.directories': '目录',
'files.search.empty': '没有匹配搜索的文件。',
'files.search.error.failed': '搜索文件失败。',
'files.search.error.failedWithDetail': '搜索文件失败:{error}',
'files.changes.section.staged': '已暂存变更({n}',
'files.changes.section.unstaged': '未暂存变更({n}',
'files.changes.empty.unavailable': 'Git 状态不可用。请使用“目录”浏览全部文件,或直接搜索。',
'files.changes.empty.none': '未检测到变更。请使用“目录”浏览全部文件,或直接搜索。',
'files.changes.error.gitStatusUnavailable': 'Git 状态不可用。',
'files.changes.error.gitStatusUnavailableWithDetail': 'Git 状态不可用:{error}',
'files.changes.error.unstagedDiffUnavailableWithDetail': '未暂存 Diff 不可用:{error}',
'files.changes.error.stagedDiffUnavailableWithDetail': '已暂存 Diff 不可用:{error}',
'files.directories.empty': '空目录。',
'files.directories.error.listFailed': '加载目录失败。',
'files.directories.error.listFailedWithDetail': '加载目录失败:{error}',
// File page
'file.page.fallbackName': '文件',
'file.page.unknownPath': '未知路径',
'file.page.copyPath': '复制路径',
'file.page.copyContent': '复制文件内容',
'file.page.tab.diff': 'Diff',
'file.page.tab.file': '文件',
'file.page.missingPath': '未提供文件路径。',
'file.page.binary': '该文件看起来是二进制文件,无法显示。',
'file.page.empty': '文件为空。',
'file.page.noChanges': '没有可显示的变更。',
'file.error.readFailed': '读取文件失败。',
'file.error.readFailedWithDetail': '读取文件失败:{error}',
'file.error.diffUnavailable': 'Diff 不可用。',
'file.error.diffUnavailableWithDetail': 'Diff 不可用:{error}',
// Tool card
'tool.askQuestion': '其他',
'tool.edit': '编辑',
+22 -18
View File
@@ -7,8 +7,10 @@ import { CopyIcon, CheckIcon } from '@/components/icons'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { formatDiffError, formatReadFileError } from '@/lib/files-i18n'
import { queryKeys } from '@/lib/query-keys'
import { langAlias, useShikiHighlighter } from '@/lib/shiki'
import { useTranslation } from '@/lib/use-translation'
import { decodeBase64 } from '@/lib/utils'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
@@ -73,12 +75,12 @@ function DiffDisplay(props: { diffContent: string }) {
)
}
function FileContentSkeleton() {
function FileContentSkeleton(props: { label: string }) {
const widths = ['w-full', 'w-11/12', 'w-5/6', 'w-3/4', 'w-2/3', 'w-4/5']
return (
<div role="status" aria-live="polite">
<span className="sr-only">Loading file</span>
<span className="sr-only">{props.label}</span>
<div className="animate-pulse space-y-2 rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3">
{Array.from({ length: 12 }).map((_, index) => (
<div key={`file-skeleton-${index}`} className={`h-3 ${widths[index % widths.length]} rounded bg-[var(--app-subtle-bg)]`} />
@@ -118,6 +120,7 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
export default function FilePage() {
const { api } = useAppContext()
const { t } = useTranslation()
const { copied: pathCopied, copy: copyPath } = useCopyToClipboard()
const { copied: contentCopied, copy: copyContent } = useCopyToClipboard()
const goBack = useAppGoBack()
@@ -127,7 +130,7 @@ export default function FilePage() {
const staged = search.staged
const filePath = useMemo(() => decodePath(encodedPath), [encodedPath])
const fileName = filePath.split('/').pop() || filePath || 'File'
const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName')
const diffQuery = useQuery({
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
@@ -193,7 +196,8 @@ export default function FilePage() {
? (fileContentResult.error ?? 'Failed to read file')
: null
const missingPath = !filePath
const diffErrorMessage = diffError ? `Diff unavailable: ${diffError}` : null
const diffErrorMessage = diffError ? formatDiffError(diffError, t) : null
const fileErrorMessage = fileError ? formatReadFileError(fileError, t) : null
return (
<div className="flex h-full min-h-0 flex-col">
@@ -208,7 +212,7 @@ export default function FilePage() {
</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 className="truncate text-xs text-[var(--app-hint)]">{filePath || t('file.page.unknownPath')}</div>
</div>
</div>
</div>
@@ -216,12 +220,12 @@ export default function FilePage() {
<div className="bg-[var(--app-bg)]">
<div className="mx-auto w-full max-w-content px-3 py-2 flex items-center gap-2 border-b border-[var(--app-divider)]">
<FileIcon fileName={fileName} size={20} />
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-hint)]">{filePath}</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-hint)]">{filePath || t('file.page.unknownPath')}</span>
<button
type="button"
onClick={() => copyPath(filePath)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy path"
title={t('file.page.copyPath')}
>
{pathCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
@@ -236,14 +240,14 @@ export default function FilePage() {
onClick={() => setDisplayMode('diff')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'diff' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
Diff
{t('file.page.tab.diff')}
</button>
<button
type="button"
onClick={() => setDisplayMode('file')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'file' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
File
{t('file.page.tab.file')}
</button>
</div>
</div>
@@ -257,19 +261,19 @@ export default function FilePage() {
</div>
) : null}
{missingPath ? (
<div className="text-sm text-[var(--app-hint)]">No file path provided.</div>
<div className="text-sm text-[var(--app-hint)]">{t('file.page.missingPath')}</div>
) : loading ? (
<FileContentSkeleton />
) : fileError ? (
<div className="text-sm text-[var(--app-hint)]">{fileError}</div>
<FileContentSkeleton label={t('loading.file')} />
) : fileErrorMessage ? (
<div className="text-sm text-[var(--app-hint)]">{fileErrorMessage}</div>
) : binaryFile ? (
<div className="text-sm text-[var(--app-hint)]">
This looks like a binary file. It cannot be displayed.
{t('file.page.binary')}
</div>
) : displayMode === 'diff' && diffContent ? (
<DiffDisplay diffContent={diffContent} />
) : displayMode === 'diff' && diffError ? (
<div className="text-sm text-[var(--app-hint)]">{diffError}</div>
<div className="text-sm text-[var(--app-hint)]">{diffErrorMessage}</div>
) : displayMode === 'file' ? (
decodedContent ? (
<div className="relative">
@@ -278,7 +282,7 @@ export default function FilePage() {
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title="Copy file content"
title={t('file.page.copyContent')}
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
@@ -288,10 +292,10 @@ export default function FilePage() {
</pre>
</div>
) : (
<div className="text-sm text-[var(--app-hint)]">File is empty.</div>
<div className="text-sm text-[var(--app-hint)]">{t('file.page.empty')}</div>
)
) : (
<div className="text-sm text-[var(--app-hint)]">No changes to display.</div>
<div className="text-sm text-[var(--app-hint)]">{t('file.page.noChanges')}</div>
)}
</div>
</div>
+39 -18
View File
@@ -8,9 +8,16 @@ import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles'
import { useSession } from '@/hooks/queries/useSession'
import { useSessionFileSearch } from '@/hooks/queries/useSessionFileSearch'
import {
formatFileSearchError,
formatGitStatusError,
getDetachedBranchLabel,
getProjectRootLabel,
} from '@/lib/files-i18n'
import { encodeBase64 } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import { useQueryClient } from '@tanstack/react-query'
import { useTranslation } from '@/lib/use-translation'
function BackIcon(props: { className?: string }) {
return (
@@ -160,7 +167,8 @@ function GitFileRow(props: {
onOpen: () => void
showDivider: boolean
}) {
const subtitle = props.file.filePath || 'project root'
const { t } = useTranslation()
const subtitle = getProjectRootLabel(props.file.filePath, t)
return (
<button
@@ -186,7 +194,8 @@ function SearchResultRow(props: {
onOpen: () => void
showDivider: boolean
}) {
const subtitle = props.file.filePath || 'project root'
const { t } = useTranslation()
const subtitle = getProjectRootLabel(props.file.filePath, t)
const icon = props.file.fileType === 'file'
? <FileIcon fileName={props.file.fileName} size={22} />
: <FolderIcon className="text-[var(--app-link)]" />
@@ -229,6 +238,7 @@ function FileListSkeleton(props: { label: string; rows?: number }) {
export default function FilesPage() {
const { api } = useAppContext()
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const goBack = useAppGoBack()
@@ -268,9 +278,17 @@ export default function FilesPage() {
})
}, [activeTab, navigate, sessionId])
const branchLabel = gitStatus?.branch ?? 'detached'
const branchLabel = getDetachedBranchLabel(gitStatus?.branch, t)
const subtitle = session?.metadata?.path ?? sessionId
const showGitErrorBanner = Boolean(gitError)
const gitErrorMessage = useMemo(
() => (gitError ? formatGitStatusError(gitError, t) : null),
[gitError, t]
)
const searchErrorMessage = useMemo(
() => (searchResults.error ? formatFileSearchError(searchResults.error, t) : null),
[searchResults.error, t]
)
const rootLabel = useMemo(() => {
const base = session?.metadata?.path ?? sessionId
const parts = base.split(/[/\\]/).filter(Boolean)
@@ -317,14 +335,14 @@ export default function FilesPage() {
<BackIcon />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">Files</div>
<div className="truncate font-semibold">{t('files.page.title')}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{subtitle}</div>
</div>
<button
type="button"
onClick={handleRefresh}
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"
title={t('files.page.refresh')}
>
<RefreshIcon />
</button>
@@ -338,7 +356,7 @@ export default function FilesPage() {
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search files"
placeholder={t('files.page.searchPlaceholder')}
className="w-full bg-transparent text-sm text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none"
autoCapitalize="none"
autoCorrect="off"
@@ -356,7 +374,7 @@ export default function FilesPage() {
onClick={() => handleTabChange('changes')}
className={`relative py-3 text-center text-sm font-semibold transition-colors hover:bg-[var(--app-subtle-bg)] ${activeTab === 'changes' ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}
>
Changes
{t('files.tab.changes')}
<span
className={`absolute bottom-0 left-1/2 h-0.5 w-10 -translate-x-1/2 rounded-full ${activeTab === 'changes' ? 'bg-[var(--app-link)]' : 'bg-transparent'}`}
/>
@@ -368,7 +386,7 @@ export default function FilesPage() {
onClick={() => handleTabChange('directories')}
className={`relative py-3 text-center text-sm font-semibold transition-colors hover:bg-[var(--app-subtle-bg)] ${activeTab === 'directories' ? 'text-[var(--app-fg)]' : 'text-[var(--app-hint)]'}`}
>
Directories
{t('files.tab.directories')}
<span
className={`absolute bottom-0 left-1/2 h-0.5 w-10 -translate-x-1/2 rounded-full ${activeTab === 'directories' ? 'bg-[var(--app-link)]' : 'bg-transparent'}`}
/>
@@ -384,7 +402,10 @@ export default function FilesPage() {
<span className="font-semibold">{branchLabel}</span>
</div>
<div className="text-xs text-[var(--app-hint)]">
{gitStatus.totalStaged} staged, {gitStatus.totalUnstaged} unstaged
{t('files.branch.summary', {
staged: gitStatus.totalStaged,
unstaged: gitStatus.totalUnstaged,
})}
</div>
</div>
</div>
@@ -394,17 +415,17 @@ export default function FilesPage() {
<div className="mx-auto w-full max-w-content">
{showGitErrorBanner && activeTab === 'changes' ? (
<div className="border-b border-[var(--app-divider)] bg-amber-500/10 px-3 py-2 text-xs text-[var(--app-hint)]">
{gitError}
{gitErrorMessage}
</div>
) : null}
{shouldSearch ? (
searchResults.isLoading ? (
<FileListSkeleton label="Loading files…" />
<FileListSkeleton label={t('loading.files')} />
) : searchResults.error ? (
<div className="p-6 text-sm text-[var(--app-hint)]">{searchResults.error}</div>
<div className="p-6 text-sm text-[var(--app-hint)]">{searchErrorMessage}</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.'}
{t('files.search.empty')}
</div>
) : (
<div className="border-t border-[var(--app-divider)]">
@@ -426,13 +447,13 @@ export default function FilesPage() {
onOpenFile={(path) => handleOpenFile(path)}
/>
) : gitLoading ? (
<FileListSkeleton label="Loading Git status…" />
<FileListSkeleton label={t('loading.git')} />
) : (
<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})
{t('files.changes.section.staged', { n: gitStatus.stagedFiles.length })}
</div>
{gitStatus.stagedFiles.map((file, index) => (
<GitFileRow
@@ -448,7 +469,7 @@ export default function FilesPage() {
{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})
{t('files.changes.section.unstaged', { n: gitStatus.unstagedFiles.length })}
</div>
{gitStatus.unstagedFiles.map((file, index) => (
<GitFileRow
@@ -463,13 +484,13 @@ export default function FilesPage() {
{!gitStatus ? (
<div className="p-6 text-sm text-[var(--app-hint)]">
Git status unavailable. Use Directories to browse all files, or search.
{t('files.changes.empty.unavailable')}
</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 Directories to browse all files, or search.
{t('files.changes.empty.none')}
</div>
) : null}
</div>