Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions web/src/components/SessionFiles/DirectoryTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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>
Expand Down
63 changes: 63 additions & 0 deletions web/src/lib/files-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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('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')
})
})
75 changes: 75 additions & 0 deletions web/src/lib/files-i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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))
}

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 unstagedDetail = stripPrefix(detail, 'Unstaged diff unavailable: ')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] This only handles one prefixed failure, but useGitStatusFiles can concatenate both diff errors into a single string (Unstaged ... Staged ...), so the second failure is folded into the translated detail and lost.

Suggested fix:

const segments = detail.split(/(?=Unstaged diff unavailable: |Staged diff unavailable: )/).filter(Boolean)
if (segments.length > 1) {
  return segments.map((segment) => {
    const unstaged = stripPrefix(segment, 'Unstaged diff unavailable: ')\n    if (unstaged) return t('files.changes.error.unstagedDiffUnavailableWithDetail', { error: unstaged })\n    const staged = stripPrefix(segment, 'Staged diff unavailable: ')\n    if (staged) return t('files.changes.error.stagedDiffUnavailableWithDetail', { error: staged })\n    return segment\n  }).join(' ')\n}\n```

Copy link
Copy Markdown
Contributor Author

@junxin367 junxin367 May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3417f90. formatGitStatusError now splits combined Unstaged ... Staged ... failures into segments and translates each one independently, so both errors are preserved. Added regression coverage in web/src/lib/files-i18n.test.ts.

if (unstagedDetail) {
return t('files.changes.error.unstagedDiffUnavailableWithDetail', { error: unstagedDetail })
}

const stagedDetail = stripPrefix(detail, 'Staged diff unavailable: ')
if (stagedDetail) {
return t('files.changes.error.stagedDiffUnavailableWithDetail', { error: stagedDetail })
}

return t('files.changes.error.gitStatusUnavailableWithDetail', { error: detail })
}

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 changes: 41 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…',
Expand Down Expand Up @@ -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',
Expand Down
41 changes: 41 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default {
'authorizing': '认证中…',
'loading.session': '加载会话…',
'loading.git': '加载 Git 状态…',
'loading.file': '加载文件…',
'loading.files': '加载文件…',
'loading.messages': '加载消息…',
'loading.machines': '加载机器…',
Expand Down Expand Up @@ -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': '编辑',
Expand Down
Loading
Loading