diff --git a/web/src/components/SessionFiles/DirectoryTree.tsx b/web/src/components/SessionFiles/DirectoryTree.tsx index 1fe7581d5b..80b1e532a2 100644 --- a/web/src/components/SessionFiles/DirectoryTree.tsx +++ b/web/src/components/SessionFiles/DirectoryTree.tsx @@ -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 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 ? ( ) : error ? ( - + ) : (
{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')}
) : null} diff --git a/web/src/lib/files-i18n.test.ts b/web/src/lib/files-i18n.test.ts new file mode 100644 index 0000000000..79aa4c1fef --- /dev/null +++ b/web/src/lib/files-i18n.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + formatDiffError, + formatDirectoryError, + formatFileSearchError, + formatGitStatusError, + formatReadFileError, + getDetachedBranchLabel, + getProjectRootLabel, +} from './files-i18n' + +const translations: Record = { + '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 { + 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') + }) +}) diff --git a/web/src/lib/files-i18n.ts b/web/src/lib/files-i18n.ts new file mode 100644 index 0000000000..09be6867c4 --- /dev/null +++ b/web/src/lib/files-i18n.ts @@ -0,0 +1,90 @@ +type Translate = (key: string, params?: Record) => 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 }) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index e719dc882d..b8d109f7e5 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 55763025e5..57b4932909 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '编辑', diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index cb06a1da4e..ee73182349 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -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 (
- Loading file… + {props.label}
{Array.from({ length: 12 }).map((_, index) => (
@@ -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 (
@@ -208,7 +212,7 @@ export default function FilePage() {
{fileName}
-
{filePath || 'Unknown path'}
+
{filePath || t('file.page.unknownPath')}
@@ -216,12 +220,12 @@ export default function FilePage() {
- {filePath} + {filePath || t('file.page.unknownPath')} @@ -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')}
@@ -257,19 +261,19 @@ export default function FilePage() {
) : null} {missingPath ? ( -
No file path provided.
+
{t('file.page.missingPath')}
) : loading ? ( - - ) : fileError ? ( -
{fileError}
+ + ) : fileErrorMessage ? ( +
{fileErrorMessage}
) : binaryFile ? (
- This looks like a binary file. It cannot be displayed. + {t('file.page.binary')}
) : displayMode === 'diff' && diffContent ? ( ) : displayMode === 'diff' && diffError ? ( -
{diffError}
+
{diffErrorMessage}
) : displayMode === 'file' ? ( decodedContent ? (
@@ -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 ? : } @@ -288,10 +292,10 @@ export default function FilePage() {
) : ( -
File is empty.
+
{t('file.page.empty')}
) ) : ( -
No changes to display.
+
{t('file.page.noChanges')}
)}
diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index ca28e3d05c..f8c698d76d 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -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 (
-
Files
+
{t('files.page.title')}
{subtitle}
@@ -338,7 +356,7 @@ export default function FilesPage() { 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')} @@ -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')} @@ -384,7 +402,10 @@ export default function FilesPage() { {branchLabel}
- {gitStatus.totalStaged} staged, {gitStatus.totalUnstaged} unstaged + {t('files.branch.summary', { + staged: gitStatus.totalStaged, + unstaged: gitStatus.totalUnstaged, + })}
@@ -394,17 +415,17 @@ export default function FilesPage() {
{showGitErrorBanner && activeTab === 'changes' ? (
- {gitError} + {gitErrorMessage}
) : null} {shouldSearch ? ( searchResults.isLoading ? ( - + ) : searchResults.error ? ( -
{searchResults.error}
+
{searchErrorMessage}
) : searchResults.files.length === 0 ? (
- {searchQuery ? 'No files match your search.' : 'No files found in this project.'} + {t('files.search.empty')}
) : (
@@ -426,13 +447,13 @@ export default function FilesPage() { onOpenFile={(path) => handleOpenFile(path)} /> ) : gitLoading ? ( - + ) : (
{gitStatus?.stagedFiles.length ? (
- Staged Changes ({gitStatus.stagedFiles.length}) + {t('files.changes.section.staged', { n: gitStatus.stagedFiles.length })}
{gitStatus.stagedFiles.map((file, index) => (
- Unstaged Changes ({gitStatus.unstagedFiles.length}) + {t('files.changes.section.unstaged', { n: gitStatus.unstagedFiles.length })}
{gitStatus.unstagedFiles.map((file, index) => ( - Git status unavailable. Use Directories to browse all files, or search. + {t('files.changes.empty.unavailable')}
) : null} {gitStatus && gitStatus.stagedFiles.length === 0 && gitStatus.unstagedFiles.length === 0 ? (
- No changes detected. Use Directories to browse all files, or search. + {t('files.changes.empty.none')}
) : null}