mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): 完善 Files 页面 i18n (#607)
* fix(web): 补全 Files 页面 i18n * fix(web): 修正 Files git 错误聚合翻译
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '编辑',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user