Dev 1.5.0 #159

Merged
LukeGus merged 28 commits from dev-1.5.0 into main 2025-09-03 05:14:50 +00:00
10 changed files with 94 additions and 39 deletions
Showing only changes of commit 654ac3e693 - Show all commits
+22 -4
View File
@@ -14,7 +14,9 @@
}, },
"homepage": { "homepage": {
"loggedInTitle": "Logged in!", "loggedInTitle": "Logged in!",
"loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar." "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.",
"failedToLoadAlerts": "Failed to load alerts",
"failedToDismissAlert": "Failed to dismiss alert"
}, },
"common": { "common": {
"close": "Close", "close": "Close",
@@ -34,7 +36,14 @@
"sidebar": "Sidebar", "sidebar": "Sidebar",
"home": "Home", "home": "Home",
"expired": "Expired", "expired": "Expired",
"expiresToday": "Expires today",
"expiresTomorrow": "Expires tomorrow",
"expiresInDays": "Expires in {{days}} days",
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"sshPath": "SSH Path",
"localPath": "Local Path",
"loading": "Loading...",
"noAuthCredentials": "No authentication credentials available for this SSH host",
"noReleases": "No Releases", "noReleases": "No Releases",
"updatesAndReleases": "Updates & Releases", "updatesAndReleases": "Updates & Releases",
"newVersionAvailable": "A new version ({{version}}) is available.", "newVersionAvailable": "A new version ({{version}}) is available.",
@@ -402,7 +411,15 @@
"enterFolderPath": "Enter folder path", "enterFolderPath": "Enter folder path",
"noShortcuts": "No shortcuts.", "noShortcuts": "No shortcuts.",
"searchFilesAndFolders": "Search files and folders...", "searchFilesAndFolders": "Search files and folders...",
"noFilesOrFoldersFound": "No files or folders found." "noFilesOrFoldersFound": "No files or folders found.",
"failedToConnectSSH": "Failed to connect to SSH",
"failedToReconnectSSH": "Failed to reconnect SSH session",
"failedToListFiles": "Failed to list files",
"fetchHomeDataTimeout": "Fetch home data timed out",
"sshStatusCheckTimeout": "SSH status check timed out",
"sshReconnectionTimeout": "SSH reconnection timed out",
"saveOperationTimeout": "Save operation timed out",
"cannotSaveFile": "Cannot save file"
}, },
"tunnels": { "tunnels": {
"title": "SSH Tunnels", "title": "SSH Tunnels",
@@ -457,7 +474,8 @@
"portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", "portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "Disconnect", "disconnect": "Disconnect",
"connect": "Connect", "connect": "Connect",
"canceling": "Canceling..." "canceling": "Canceling...",
"endpointHostNotFound": "Endpoint host not found"
}, },
"serverStats": { "serverStats": {
"title": "Server Statistics", "title": "Server Statistics",
@@ -509,7 +527,7 @@
"passwordReset": "Password reset link sent", "passwordReset": "Password reset link sent",
"twoFactorAuth": "Two-Factor Authentication", "twoFactorAuth": "Two-Factor Authentication",
"enterCode": "Enter verification code", "enterCode": "Enter verification code",
"backupCode": "Use backup code", "backupCode": "Or use backup code",
"verifyCode": "Verify Code", "verifyCode": "Verify Code",
"enableTwoFactor": "Enable Two-Factor Authentication", "enableTwoFactor": "Enable Two-Factor Authentication",
"disableTwoFactor": "Disable Two-Factor Authentication", "disableTwoFactor": "Disable Two-Factor Authentication",
+21 -3
View File
@@ -14,7 +14,9 @@
}, },
"homepage": { "homepage": {
"loggedInTitle": "登录成功!", "loggedInTitle": "登录成功!",
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。" "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
"failedToLoadAlerts": "加载警报失败",
"failedToDismissAlert": "关闭警报失败"
}, },
"common": { "common": {
"close": "关闭", "close": "关闭",
@@ -34,7 +36,14 @@
"sidebar": "侧边栏", "sidebar": "侧边栏",
"home": "首页", "home": "首页",
"expired": "已过期", "expired": "已过期",
"expiresToday": "今天过期",
"expiresTomorrow": "明天过期",
"expiresInDays": "{{days}} 天后过期",
"updateAvailable": "有可用更新", "updateAvailable": "有可用更新",
"sshPath": "SSH 路径",
"localPath": "本地路径",
"loading": "加载中...",
"noAuthCredentials": "此 SSH 主机没有可用的身份验证凭据",
"noReleases": "没有发布版本", "noReleases": "没有发布版本",
"updatesAndReleases": "更新与发布", "updatesAndReleases": "更新与发布",
"newVersionAvailable": "有新版本 ({{version}}) 可用。", "newVersionAvailable": "有新版本 ({{version}}) 可用。",
@@ -439,7 +448,15 @@
"enterFolderPath": "输入文件夹路径", "enterFolderPath": "输入文件夹路径",
"noShortcuts": "没有快捷方式。", "noShortcuts": "没有快捷方式。",
"searchFilesAndFolders": "搜索文件和文件夹...", "searchFilesAndFolders": "搜索文件和文件夹...",
"noFilesOrFoldersFound": "没有找到文件或文件夹。" "noFilesOrFoldersFound": "没有找到文件或文件夹。",
"failedToConnectSSH": "连接 SSH 失败",
"failedToReconnectSSH": "重新连接 SSH 会话失败",
"failedToListFiles": "列出文件失败",
"fetchHomeDataTimeout": "获取主页数据超时",
"sshStatusCheckTimeout": "SSH 状态检查超时",
"sshReconnectionTimeout": "SSH 重新连接超时",
"saveOperationTimeout": "保存操作超时",
"cannotSaveFile": "无法保存文件"
}, },
"tunnels": { "tunnels": {
"title": "SSH 隧道", "title": "SSH 隧道",
@@ -494,7 +511,8 @@
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", "portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "断开连接", "disconnect": "断开连接",
"connect": "连接", "connect": "连接",
"canceling": "取消中..." "canceling": "取消中...",
"endpointHostNotFound": "未找到端点主机"
}, },
"serverStats": { "serverStats": {
"title": "服务器统计", "title": "服务器统计",
+14 -5
View File
@@ -1311,17 +1311,26 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
const targetUserId = targetUser[0].id; const targetUserId = targetUser[0].id;
try { try {
// Delete from tables that have foreign keys to both users and ssh_data first
// These must be deleted before ssh_data because they reference host_id
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
// Delete from tables that only reference users
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
// Delete from ssh_data (which references users)
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
// Delete from any additional tables that might exist
db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM shared_hosts WHERE original_user_id = ? OR shared_with_user_id = ?').run(targetUserId, targetUserId); db.$client.prepare('DELETE FROM shared_hosts WHERE original_user_id = ? OR shared_with_user_id = ?').run(targetUserId, targetUserId);
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
} catch (cleanupError) { } catch (cleanupError) {
logger.error(`Cleanup failed for user ${username}:`, cleanupError); logger.error(`Cleanup failed for user ${username}:`, cleanupError);
throw cleanupError; // Re-throw to prevent user deletion if cleanup fails
} }
await db.delete(users).where(eq(users.id, targetUserId)); await db.delete(users).where(eq(users.id, targetUserId));
+5 -5
View File
@@ -136,7 +136,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
]); ]);
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000) setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000)
); );
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any]; const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
@@ -371,7 +371,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
try { try {
const statusPromise = getSSHStatus(tab.sshSessionId); const statusPromise = getSSHStatus(tab.sshSessionId);
const statusTimeoutPromise = new Promise((_, reject) => const statusTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH status check timed out')), 10000) setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000)
); );
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean }; const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
@@ -386,7 +386,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
keyPassword: currentHost.keyPassword keyPassword: currentHost.keyPassword
}); });
const connectTimeoutPromise = new Promise((_, reject) => const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000) setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
); );
await Promise.race([connectPromise, connectTimeoutPromise]); await Promise.race([connectPromise, connectTimeoutPromise]);
@@ -397,7 +397,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content); const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => { setTimeout(() => {
reject(new Error('Save operation timed out')); reject(new Error(t('fileManager.saveOperationTimeout')));
}, 30000) }, 30000)
); );
@@ -432,7 +432,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
}); });
} catch (err) { } catch (err) {
let errorMessage = formatErrorMessage(err, 'Cannot save file'); let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile'));
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) { if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
errorMessage = t('fileManager.saveTimeout'); errorMessage = t('fileManager.saveTimeout');
@@ -128,7 +128,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try { try {
if (!server.password && !server.key) { if (!server.password && !server.key) {
toast.error('No authentication credentials available for this SSH host'); toast.error(t('common.noAuthCredentials'));
return null; return null;
} }
@@ -152,7 +152,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
return sessionId; return sessionId;
} catch (err: any) { } catch (err: any) {
toast.error(err?.response?.data?.error || 'Failed to connect to SSH'); toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH'));
setSshSessionId(null); setSshSessionId(null);
return null; return null;
} finally { } finally {
@@ -189,7 +189,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
} else { } else {
throw new Error('Failed to reconnect SSH session'); throw new Error(t('fileManager.failedToReconnectSSH'));
} }
} else { } else {
res = await listSSHFiles(sshSessionId, currentPath); res = await listSSHFiles(sshSessionId, currentPath);
@@ -220,7 +220,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
} }
} catch (err: any) { } catch (err: any) {
setFiles([]); setFiles([]);
toast.error(err?.response?.data?.error || err?.message || 'Failed to list files'); toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles'));
} finally { } finally {
setFilesLoading(false); setFilesLoading(false);
setFetchingFiles(false); setFetchingFiles(false);
@@ -334,7 +334,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
fetchFiles(); fetchFiles();
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to rename item'); toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
} }
}; };
@@ -350,7 +350,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
fetchFiles(); fetchFiles();
} }
} catch (error: any) { } catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to delete item'); toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
} }
}; };
@@ -3,6 +3,7 @@ import {Button} from '@/components/ui/button.tsx';
import {Card} from '@/components/ui/card.tsx'; import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx'; import {Separator} from '@/components/ui/separator.tsx';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react'; import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
import {useTranslation} from 'react-i18next';
interface SSHConnection { interface SSHConnection {
id: string; id: string;
@@ -61,16 +62,18 @@ export function FileManagerLeftSidebarFileViewer({
onSwitchToSSH, onSwitchToSSH,
currentSSH, currentSSH,
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
const {t} = useTranslation();
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto"> <div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span <span
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span> className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
<span className="text-xs text-white truncate">{currentPath}</span> <span className="text-xs text-white truncate">{currentPath}</span>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="text-xs text-muted-foreground">Loading...</div> <div className="text-xs text-muted-foreground">{t('common.loading')}</div>
) : error ? ( ) : error ? (
<div className="text-xs text-red-500">{error}</div> <div className="text-xs text-red-500">{error}</div>
) : ( ) : (
+7 -4
View File
@@ -3,6 +3,7 @@ import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx"; import {Badge} from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
import {useTranslation} from "react-i18next";
interface TermixAlert { interface TermixAlert {
id: string; id: string;
@@ -64,6 +65,8 @@ const getTypeBadgeVariant = (type?: string) => {
}; };
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
const {t} = useTranslation();
if (!alert) { if (!alert) {
return null; return null;
} }
@@ -79,10 +82,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
const diffTime = expiryDate.getTime() - now.getTime(); const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return 'Expired'; if (diffDays < 0) return t('common.expired');
if (diffDays === 0) return 'Expires today'; if (diffDays === 0) return t('common.expiresToday');
if (diffDays === 1) return 'Expires tomorrow'; if (diffDays === 1) return t('common.expiresTomorrow');
return `Expires in ${diffDays} days`; return t('common.expiresInDays', {days: diffDays});
}; };
return ( return (
+4 -2
View File
@@ -2,6 +2,7 @@ import React, {useEffect, useState} from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
interface TermixAlert { interface TermixAlert {
id: string; id: string;
@@ -20,6 +21,7 @@ interface AlertManagerProps {
} }
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
const {t} = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]); const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -57,7 +59,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
setAlerts(sortedAlerts); setAlerts(sortedAlerts);
setCurrentAlertIndex(0); setCurrentAlertIndex(0);
} catch (err) { } catch (err) {
setError('Failed to load alerts'); setError(t('homepage.failedToLoadAlerts'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -81,7 +83,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
return prevIndex; return prevIndex;
}); });
} catch (err) { } catch (err) {
setError('Failed to dismiss alert'); setError(t('homepage.failedToDismissAlert'));
} }
}; };
+6 -6
View File
@@ -120,7 +120,7 @@ export function LeftSidebar({
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => { const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return; if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any); const id = addTab({type: 'ssh_manager'} as any);
setCurrentTab(id); setCurrentTab(id);
}; };
const adminTab = tabList.find((t) => t.type === 'admin'); const adminTab = tabList.find((t) => t.type === 'admin');
@@ -130,7 +130,7 @@ export function LeftSidebar({
setCurrentTab(adminTab.id); setCurrentTab(adminTab.id);
return; return;
} }
const id = addTab({type: 'admin', title: 'Admin'} as any); const id = addTab({type: 'admin'} as any);
setCurrentTab(id); setCurrentTab(id);
}; };
@@ -186,7 +186,7 @@ export function LeftSidebar({
}, 50); }, 50);
} }
} catch (err: any) { } catch (err: any) {
setHostsError('Failed to load hosts'); setHostsError(t('leftSidebar.failedToLoadHosts'));
} }
}, []); }, []);
@@ -229,7 +229,7 @@ export function LeftSidebar({
const hostsByFolder = React.useMemo(() => { const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {}; const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => { filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
if (!map[folder]) map[folder] = []; if (!map[folder]) map[folder] = [];
map[folder].push(h); map[folder].push(h);
}); });
@@ -239,8 +239,8 @@ export function LeftSidebar({
const sortedFolders = React.useMemo(() => { const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder); const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => { folders.sort((a, b) => {
if (a === 'No Folder') return -1; if (a === t('leftSidebar.noFolder')) return -1;
if (b === 'No Folder') return 1; if (b === t('leftSidebar.noFolder')) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
return folders; return folders;
+4 -2
View File
@@ -1,4 +1,5 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
export interface Tab { export interface Tab {
id: number; id: number;
@@ -34,15 +35,16 @@ interface TabProviderProps {
} }
export function TabProvider({children}: TabProviderProps) { export function TabProvider({children}: TabProviderProps) {
const {t} = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([ const [tabs, setTabs] = useState<Tab[]>([
{id: 1, type: 'home', title: 'Home'} {id: 1, type: 'home', title: t('nav.home')}
]); ]);
const [currentTab, setCurrentTab] = useState<number>(1); const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2); const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal'));
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle; const root = match ? match[1] : baseTitle;