Implements a zero-dependency i18n framework with TypeScript type safety. Thanks to @gy212 for the original i18n infrastructure design (PR #89) and comprehensive translation files (PR #90-96) that inspired this implementation. ## New files (5) - src/i18n/en.ts: English translations (source of truth, ~270 keys), exports TranslationKey type for compile-time key completeness - src/i18n/zh.ts: Chinese translations, typed as Record<TranslationKey, string> - src/i18n/index.ts: translate() function with {param} interpolation, Locale type, SUPPORTED_LOCALES array - src/components/layout/I18nProvider.tsx: React Context provider, loads/persists locale preference via /api/settings/app - src/hooks/useTranslation.ts: useTranslation() hook returning { locale, setLocale, t } ## Infrastructure wiring - src/app/layout.tsx: I18nProvider wrapped inside ThemeProvider - src/app/api/settings/app/route.ts: added "locale" to ALLOWED_KEYS for persistence ## Language picker - src/components/settings/GeneralSection.tsx: added language selection dropdown (Select component) in Settings > General, using SUPPORTED_LOCALES and setLocale() ## String replacements (33 component files) All hardcoded user-facing strings replaced with t() calls: High priority (chat): - ChatListPanel.tsx: session list, search, empty state, relative time - NavRail.tsx: nav labels, theme toggle, auto-approve indicator - MessageInput.tsx: command descriptions, mode labels, attach tooltip - MessageList.tsx: empty state, load earlier, loading indicator - StreamingMessage.tsx: thinking, permission actions - chat/[id]/page.tsx: session title fallback Settings: - GeneralSection.tsx: update card, auto-approve section, warning dialog - SettingsLayout.tsx: title, description, sidebar labels - CliSettingsSection.tsx: form/json tabs, buttons, dynamic field labels (skipDangerousModePermissionPrompt etc.), Enabled/Disabled states - ProviderForm.tsx: dialog title, form labels, buttons - ProviderManager.tsx: status badges, delete dialog, section headers Extensions: - extensions/page.tsx: title, tab labels - SkillsManager.tsx: empty state, loading, search - SkillEditor.tsx: toolbar buttons, placeholder - SkillListItem.tsx: delete confirmation - CreateSkillDialog.tsx: form labels, scope, templates, validation Plugins: - McpManager.tsx: header, tabs, loading state - McpServerList.tsx: empty state, badges, field labels - McpServerEditor.tsx: dialog title, form labels, buttons - ConfigEditor.tsx: save/format buttons Layout: - RightPanel.tsx: panel header, open/close tooltips - FileTree.tsx: search placeholder, empty states - FilePreview.tsx: back button, line count, copy path - DocPreview.tsx: iframe title - ConnectionStatus.tsx: install prompts, version display - InstallWizard.tsx: phase descriptions, button labels - ImportSessionDialog.tsx: search, relative time, import buttons - FolderPicker.tsx: dialog title, loading, buttons - TaskList.tsx: filter tabs, input placeholder, empty states
202 lines
6.1 KiB
TypeScript
202 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import { RefreshIcon, Search01Icon, SourceCodeIcon, CodeIcon, File01Icon } from "@hugeicons/core-free-icons";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { cn } from "@/lib/utils";
|
|
import type { FileTreeNode } from "@/types";
|
|
import {
|
|
FileTree as AIFileTree,
|
|
FileTreeFolder,
|
|
FileTreeFile,
|
|
} from "@/components/ai-elements/file-tree";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
import type { ReactNode } from "react";
|
|
|
|
interface FileTreeProps {
|
|
workingDirectory: string;
|
|
onFileSelect: (path: string) => void;
|
|
onFileAdd?: (path: string) => void;
|
|
}
|
|
|
|
function getFileIcon(extension?: string): ReactNode {
|
|
switch (extension) {
|
|
case "ts":
|
|
case "tsx":
|
|
case "js":
|
|
case "jsx":
|
|
case "py":
|
|
case "rb":
|
|
case "rs":
|
|
case "go":
|
|
case "java":
|
|
case "c":
|
|
case "cpp":
|
|
case "h":
|
|
case "hpp":
|
|
case "cs":
|
|
case "swift":
|
|
case "kt":
|
|
case "dart":
|
|
case "lua":
|
|
case "php":
|
|
case "zig":
|
|
return <HugeiconsIcon icon={SourceCodeIcon} className="size-4 text-muted-foreground" />;
|
|
case "json":
|
|
case "yaml":
|
|
case "yml":
|
|
case "toml":
|
|
return <HugeiconsIcon icon={CodeIcon} className="size-4 text-muted-foreground" />;
|
|
case "md":
|
|
case "mdx":
|
|
case "txt":
|
|
case "csv":
|
|
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
|
|
default:
|
|
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
|
|
}
|
|
}
|
|
|
|
function containsMatch(node: FileTreeNode, query: string): boolean {
|
|
const q = query.toLowerCase();
|
|
if (node.name.toLowerCase().includes(q)) return true;
|
|
if (node.children) {
|
|
return node.children.some((child) => containsMatch(child, q));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
|
|
if (!query) return nodes;
|
|
return nodes
|
|
.filter((node) => containsMatch(node, query))
|
|
.map((node) => ({
|
|
...node,
|
|
children: node.children ? filterTree(node.children, query) : undefined,
|
|
}));
|
|
}
|
|
|
|
function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) {
|
|
const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes;
|
|
|
|
return (
|
|
<>
|
|
{filtered.map((node) => {
|
|
if (node.type === "directory") {
|
|
return (
|
|
<FileTreeFolder key={node.path} path={node.path} name={node.name}>
|
|
{node.children && (
|
|
<RenderTreeNodes nodes={node.children} searchQuery={searchQuery} />
|
|
)}
|
|
</FileTreeFolder>
|
|
);
|
|
}
|
|
return (
|
|
<FileTreeFile
|
|
key={node.path}
|
|
path={node.path}
|
|
name={node.name}
|
|
icon={getFileIcon(node.extension)}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTreeProps) {
|
|
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const { t } = useTranslation();
|
|
|
|
const fetchTree = useCallback(async () => {
|
|
if (!workingDirectory) {
|
|
setTree([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/files?dir=${encodeURIComponent(workingDirectory)}&baseDir=${encodeURIComponent(workingDirectory)}&depth=4&_t=${Date.now()}`
|
|
);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setTree(data.tree || []);
|
|
} else {
|
|
setTree([]);
|
|
}
|
|
} catch {
|
|
setTree([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workingDirectory]);
|
|
|
|
useEffect(() => {
|
|
fetchTree();
|
|
}, [fetchTree]);
|
|
|
|
// Auto-refresh when AI finishes streaming
|
|
useEffect(() => {
|
|
const handler = () => fetchTree();
|
|
window.addEventListener('refresh-file-tree', handler);
|
|
return () => window.removeEventListener('refresh-file-tree', handler);
|
|
}, [fetchTree]);
|
|
|
|
// Default to all directories collapsed
|
|
const defaultExpanded = new Set<string>();
|
|
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
{/* Search + Refresh */}
|
|
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0">
|
|
<div className="relative flex-1 min-w-0">
|
|
<HugeiconsIcon icon={Search01Icon} className="absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
|
<Input
|
|
placeholder={t('fileTree.filterFiles')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-7 pl-7 text-xs"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={fetchTree}
|
|
disabled={loading}
|
|
className="h-7 w-7 shrink-0"
|
|
>
|
|
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
|
|
<span className="sr-only">{t('fileTree.refresh')}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tree */}
|
|
<div className="flex-1 overflow-auto">
|
|
{loading && tree.length === 0 ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<HugeiconsIcon icon={RefreshIcon} className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : tree.length === 0 ? (
|
|
<p className="py-4 text-center text-xs text-muted-foreground">
|
|
{workingDirectory ? t('fileTree.noFiles') : t('fileTree.selectFolder')}
|
|
</p>
|
|
) : (
|
|
<AIFileTree
|
|
defaultExpanded={defaultExpanded}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI Elements FileTree onSelect type conflicts with HTMLAttributes.onSelect
|
|
onSelect={onFileSelect as any}
|
|
onAdd={onFileAdd}
|
|
className="border-0 rounded-none"
|
|
>
|
|
<RenderTreeNodes nodes={tree} searchQuery={searchQuery} />
|
|
</AIFileTree>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|