Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 9 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1256,12 +1256,12 @@ function AppContent() {
setConnectionDialogOpen(true);
setPendingConnectionId(null);
} else {
toast.error('Connection Not Found', {
description: 'The connection data could not be loaded.',
toast.error(t('app.connectionNotFound'), {
description: t('app.connectionNotFoundDesc1'),
});
}
}
}, []);
}, [t]);

const handleSaveConnection = useCallback(async (config: ConnectionConfig) => {
if (!config.id) return;
Expand Down Expand Up @@ -1449,16 +1449,16 @@ function AppContent() {
const existingTab = allTabs.find(tab => tab.id === connectionId || tab.originalConnectionId === connectionId);
if (existingTab) {
handleTabSelect(existingTab.id);
toast.info('Already Connected', {
description: `Switched to existing ${existingTab.name} connection`,
toast.info(t('app.alreadyConnected'), {
description: t('app.alreadyConnectedDesc', { name: existingTab.name }),
});
return;
}

const connectionData = ConnectionStorageManager.getConnection(connectionId);
if (!connectionData) {
toast.error('Connection Not Found', {
description: 'The connection could not be found. It may have been deleted.',
toast.error(t('app.connectionNotFound'), {
description: t('app.connectionNotFoundDesc2'),
});
return;
}
Expand Down Expand Up @@ -1732,7 +1732,7 @@ function AppContent() {
closeTabShortcut: keyboardShortcutSettings.closeTab,
onWorkingDirectoryChange: handleWorkingDirectoryChange,
}}>
<ErrorBoundary label="Terminal">
<ErrorBoundary label={t('app.terminal')}>
<GridRenderer node={state.gridLayout} path={[]} />
</ErrorBoundary>
</TerminalCallbacksProvider>
Expand All @@ -1751,7 +1751,7 @@ function AppContent() {
maxSize={50}
onResize={(size) => setBottomPanelSize(size)}
>
<ErrorBoundary label="File Browser">
<ErrorBoundary label={t('app.fileBrowser')}>
<IntegratedFileBrowser
connectionId={activeConnection.connectionId}
host={activeConnection.host}
Expand Down
57 changes: 57 additions & 0 deletions src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import i18n from '../lib/i18n';
import { changeLanguage, applyLanguageFromPreference, getLanguagePreference, AUTO } from '../lib/i18n';
import { describe, it, expect, beforeEach } from 'vitest';
import en from '../locales/en.json';
import zhCN from '../locales/zh-CN.json';

describe('i18n', () => {
it('should initialize with English', () => {
Expand Down Expand Up @@ -30,6 +34,59 @@ describe('i18n', () => {
expect(single).toContain('1 file');
expect(plural).toContain('5 files');
});

it('every t() key used in source code exists in both locales', () => {
// Guard against future hardcoded translation keys: collect every literal
// t('...') / i18n.t('...') key from the source tree and assert it resolves
// in en.json (accounting for plural/context suffixes like _one/_other).
// vitest runs from the project root, so process.cwd() reliably points at
// the repo root; scanning src/ from there keeps this ESM-safe (no
// require()/__dirname which are unavailable in "type": "module" files).
const srcDir = path.join(process.cwd(), 'src');

function flatten(d: Record<string, unknown>, prefix = ''): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(d)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object') Object.assign(out, flatten(v as Record<string, unknown>, key));
else out[key] = v;
}
return out;
}
const enFlat = flatten(en);
const zhFlat = flatten(zhCN);

const suffixes = ['one', 'other', 'zero', 'two', 'few', 'many', 'plural'];
const resolveKey = (k: string): boolean =>
k in enFlat ||
suffixes.some((s) => `${k}_${s}` in enFlat) ||
// context variants (e.g. directoryTransferDialog.toast.complete_upload)
Object.keys(enFlat).some((ek) => ek.startsWith(`${k}_`));

const used = new Set<string>();
function walk(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'locales' || entry.name === '__tests__' || entry.name === '__mocks__') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(ts|tsx)$/.test(entry.name)) {
const content = readFileSync(full, 'utf8');
for (const m of content.matchAll(/(?:\bt|i18n\.t)\(\s*['"]([^'"]+)['"]/g)) {
const key = m[1];
if (key.includes('${') || key.includes(' + ')) continue; // dynamic keys
used.add(key);
}
}
}
}
walk(srcDir);

const missing = [...used].filter((k) => !resolveKey(k)).sort();
expect(missing).toEqual([]);

// Both locale files must define exactly the same set of keys.
expect(Object.keys(zhFlat).sort()).toEqual(Object.keys(enFlat).sort());
});
});

describe('i18n language preference', () => {
Expand Down
9 changes: 5 additions & 4 deletions src/__tests__/integrated-file-browser-keyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,9 @@ describe('IntegratedFileBrowser terminal directory following', () => {
// Wait for the Home navigation to fully commit (breadcrumb renders '/home')
// before bumping the terminal sequence. Otherwise the follow effect can still
// see committedPathRef === '/srv/app' and skip reloading, making this test
// order-dependent on async timing.
await screen.findByTitle('/home');
// order-dependent on async timing. Generous timeout: slow CI runners
// (Windows) occasionally exceed the default 1000 ms commit window.
await screen.findByTitle('/home', undefined, { timeout: 5000 });

rerender(
<IntegratedFileBrowser
Expand Down Expand Up @@ -232,7 +233,7 @@ describe('IntegratedFileBrowser terminal directory following', () => {
);

await waitFor(() => expect(mocks.warning).toHaveBeenCalledOnce());
expect(await screen.findByTitle('/home')).toBeTruthy();
expect(await screen.findByTitle('/home', undefined, { timeout: 5000 })).toBeTruthy();

rerender(
<IntegratedFileBrowser
Expand All @@ -249,7 +250,7 @@ describe('IntegratedFileBrowser terminal directory following', () => {
).toHaveLength(2);
});
expect(mocks.warning).toHaveBeenCalledOnce();
expect(await screen.findByTitle('/home')).toBeTruthy();
expect(await screen.findByTitle('/home', undefined, { timeout: 5000 })).toBeTruthy();
});
});

Expand Down
10 changes: 5 additions & 5 deletions src/__tests__/update-checker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@ describe('UpdateChecker', () => {
rerender(<UpdateChecker checkSignal={1} />);
await act(async () => { await new Promise(r => setTimeout(r, 30)); });

expect(mockToast.loading).toHaveBeenCalledWith('Checking for updates…', { id: 'update-check' });
expect(mockToast.loading).toHaveBeenCalledWith('Checking for updates...', { id: 'update-check' });

// Resolve
await act(async () => { resolveCheck!(null); });
expect(mockToast.dismiss).toHaveBeenCalledWith('update-check');
expect(mockToast.success).toHaveBeenCalledWith('You are up to date.');
expect(mockToast.success).toHaveBeenCalledWith("You're up to date!");
});

it('does NOT trigger check() when signal is same value', async () => {
Expand Down Expand Up @@ -257,7 +257,7 @@ describe('UpdateChecker', () => {
await waitFor(() => expect(mockToast.error).toHaveBeenCalled());

const [title, opts] = mockToast.error.mock.calls[0];
expect(title).toBe('Update check failed');
expect(title).toBe('Check Failed');
expect(opts.description).toContain('Update server is not configured');
});

Expand Down Expand Up @@ -369,7 +369,7 @@ describe('UpdateChecker', () => {

await waitFor(() => expect(mockToast.error).toHaveBeenCalled());
const [title, opts] = mockToast.error.mock.calls[0];
expect(title).toBe('Update failed');
expect(title).toBe('Download Failed');
expect(opts.description).toBe('disk full');
});
});
Expand Down Expand Up @@ -422,7 +422,7 @@ describe('UpdateChecker', () => {

await waitFor(() => {
const calls = mockToast.error.mock.calls;
expect(calls.some(([t]: [string]) => t === 'Install failed')).toBe(true);
expect(calls.some(([t]: [string]) => t === 'Install Failed')).toBe(true);
});
});
});
Expand Down
16 changes: 9 additions & 7 deletions src/components/connection-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { X, Plus, XCircle, ArrowRight, ArrowLeft, Copy, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';
import {
Expand Down Expand Up @@ -44,6 +45,7 @@ export function ConnectionTabs({
onCloseToRight,
onCloseToLeft
}: ConnectionTabsProps) {
const { t } = useTranslation();
const duplicateTabShortcut = formatKeyboardShortcut(
'Ctrl+D',
navigator.platform.toUpperCase().includes('MAC'),
Expand Down Expand Up @@ -96,7 +98,7 @@ export function ConnectionTabs({
<>
<ContextMenuItem onClick={() => onReconnect(tab.id)}>
<RefreshCw className="mr-2 h-4 w-4" />
Reconnect
{t('connectionTabs.reconnect')}
</ContextMenuItem>
<ContextMenuSeparator />
</>
Expand All @@ -105,42 +107,42 @@ export function ConnectionTabs({
<>
<ContextMenuItem onClick={() => onDuplicateTab(tab.id)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate Tab
{t('connectionTabs.duplicateTab')}
<ContextMenuShortcut>{duplicateTabShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem onClick={() => onTabClose(tab.id)}>
<X className="mr-2 h-4 w-4" />
Close Tab
{t('connectionTabs.closeTab')}
<ContextMenuShortcut>{closeTabShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onCloseOthers && tabs.length > 1 && (
<ContextMenuItem onClick={() => onCloseOthers(tab.id)}>
<XCircle className="mr-2 h-4 w-4" />
Close Other Tabs
{t('connectionTabs.closeOtherTabs')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onCloseToLeft && index > 0 && (
<ContextMenuItem onClick={() => onCloseToLeft(tab.id)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Close Tabs to the Left
{t('connectionTabs.closeTabsToLeft')}
</ContextMenuItem>
)}
{onCloseToRight && index < tabs.length - 1 && (
<ContextMenuItem onClick={() => onCloseToRight(tab.id)}>
<ArrowRight className="mr-2 h-4 w-4" />
Close Tabs to the Right
{t('connectionTabs.closeTabsToRight')}
</ContextMenuItem>
)}
{onCloseAll && tabs.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onCloseAll}>
<XCircle className="mr-2 h-4 w-4" />
Close All Tabs
{t('connectionTabs.closeAllTabs')}
</ContextMenuItem>
</>
)}
Expand Down
9 changes: 6 additions & 3 deletions src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';
import i18n from '@/lib/i18n';

interface ErrorBoundaryProps {
children: React.ReactNode;
Expand Down Expand Up @@ -53,15 +54,17 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
<AlertTriangle className="w-8 h-8 text-destructive" />
<div className="space-y-1">
<p className="text-sm font-medium">
{this.props.label ? `${this.props.label} encountered an error` : 'Something went wrong'}
{this.props.label
? i18n.t('errorBoundary.labelError', { label: this.props.label })
: i18n.t('errorBoundary.somethingWentWrong')}
</p>
<p className="text-xs text-muted-foreground max-w-[300px] break-words">
{this.state.error?.message || 'An unexpected error occurred'}
{this.state.error?.message || i18n.t('errorBoundary.unexpectedError')}
</p>
</div>
<Button variant="outline" size="sm" onClick={this.handleReset} className="gap-1.5">
<RefreshCw className="w-3 h-3" />
Retry
{i18n.t('errorBoundary.retry')}
</Button>
</div>
);
Expand Down
19 changes: 12 additions & 7 deletions src/components/integrated-file-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1166,7 +1166,12 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected,
};

const handleFileInfo = (file: FileItem) => {
toast.info(`File: ${file.name}\nSize: ${formatFileSize(file.size)}\nModified: ${formatDate(file.modified)}\nPermissions: ${file.permissions}`);
toast.info(t('fileBrowser.toast.fileInfo', {
name: file.name,
size: formatFileSize(file.size),
modified: formatDate(file.modified),
permissions: file.permissions,
}));
};

const handleNewFile = async () => {
Expand Down Expand Up @@ -1607,8 +1612,8 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected,
<div className="absolute inset-0 bg-accent/20 border-2 border-dashed border-primary z-50 flex items-center justify-center pointer-events-none">
<div className="bg-background/90 rounded-lg p-6 shadow-lg">
<Upload className="h-12 w-12 mx-auto mb-3 text-primary" />
<p className="font-medium">Drop files or folders to upload</p>
<p className="text-sm text-muted-foreground mt-1">Upload to {currentPath}</p>
<p className="font-medium">{t('fileBrowser.dropOverlay')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('fileBrowser.dropUploadTo', { path: currentPath })}</p>
</div>
</div>
)}
Expand Down Expand Up @@ -1787,11 +1792,11 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected,
<>
<ContextMenuItem onClick={() => handleFileDoubleClick(file)}>
<Eye className="mr-2 h-4 w-4" />
Open
{t('fileBrowser.contextMenu.open')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleFileDoubleClick(file)}>
<Edit className="mr-2 h-4 w-4" />
Edit
{t('fileBrowser.contextMenu.edit')}
</ContextMenuItem>
{onOpenInLogMonitor && (
<ContextMenuItem onClick={() => {
Expand All @@ -1813,7 +1818,7 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected,
<>
<ContextMenuItem onClick={() => handleFileDoubleClick(file)}>
<Folder className="mr-2 h-4 w-4" />
Open Folder
{t('fileBrowser.contextMenu.openFolder')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDownloadDirectory(file)}>
<FolderDown className="mr-2 h-4 w-4" />
Expand All @@ -1837,7 +1842,7 @@ export function IntegratedFileBrowser({ connectionId, host: _host, isConnected,
{clipboard && (
<ContextMenuItem onClick={handlePasteFiles}>
<ClipboardPaste className="mr-2 h-4 w-4" />
{t('fileBrowser.contextMenu.paste')} {clipboard.files.length} item(s)
{t('fileBrowser.contextMenu.pasteWithCount', { count: clipboard.files.length })}
</ContextMenuItem>
)}
<ContextMenuSeparator />
Expand Down
11 changes: 6 additions & 5 deletions src/components/network-monitor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';

// Interfaces kept for future use when re-enabling network monitoring
Expand Down Expand Up @@ -34,6 +35,7 @@ interface NetworkMonitorProps {
}

export function NetworkMonitor({ connectionId }: NetworkMonitorProps) {
const { t } = useTranslation();
// Disabled for now - will be re-enabled in future updates
// const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
// const [connections, setConnections] = useState<NetworkConnection[]>([]);
Expand Down Expand Up @@ -105,7 +107,7 @@ export function NetworkMonitor({ connectionId }: NetworkMonitorProps) {
if (!connectionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>No active connection. Connect to view network statistics.</p>
<p>{t('networkMonitor.noConnection')}</p>
</div>
);
}
Expand All @@ -114,15 +116,14 @@ export function NetworkMonitor({ connectionId }: NetworkMonitorProps) {
<div className="flex flex-col h-full overflow-auto p-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Network Statistics</CardTitle>
<CardTitle className="text-sm">{t('networkMonitor.title')}</CardTitle>
<CardDescription>
Advanced network monitoring features coming soon
{t('networkMonitor.comingSoon')}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Network Interfaces and Active Connections monitoring is currently disabled.
Check the Network Usage and Network Latency charts in the Monitor tab for current network metrics.
{t('networkMonitor.disabledNotice')}
</p>
</CardContent>
</Card>
Expand Down
Loading
Loading