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
+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>