Files
NetMesh/components/settings/tabs/SettingsSystemTab.tsx

1326 lines
54 KiB
TypeScript
Raw Permalink Normal View History

/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui";
import { cn } from "../../../lib/utils";
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
import { AppLockSettingsSection } from './AppLockSettingsSection';
interface CrashLogFile {
fileName: string;
date: string;
size: number;
entryCount: number;
}
interface CrashLogEntry {
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}
interface TempDirInfo {
path: string;
fileCount: number;
totalSize: number;
}
interface SshDebugLogInfo {
enabled: boolean;
path: string;
exists: boolean;
size: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Returns a locale-agnostic relative time string for the given timestamp. */
function formatLastChecked(
timestamp: number | null,
t: (key: string) => string,
): string {
if (!timestamp) return '';
const diffMs = Date.now() - timestamp;
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
if (diffMins < 60)
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
const diffHours = Math.floor(diffMins / 60);
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
}
interface SettingsSystemTabProps {
appLockSettings: AppLockSettings;
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
requestAppLockDisable: (currentPassword: string) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
requestAppLockPasswordChange: (input: {
currentPassword?: string;
nextPassword: string;
}) => Promise<AppLockSettings | { ok: false; error: AppLockSettingsChangeError }>;
appLockSystemUnlockStatus?: AppLockSystemUnlockStatus;
setAppLockSystemUnlockEnabled?: (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => Promise<AppLockSettings | { ok: false; error: 'empty-current' | 'incorrect' | 'locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' }>;
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
sessionLogsDir: string;
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
sessionLogsTimestampsEnabled: boolean;
setSessionLogsTimestampsEnabled: (enabled: boolean) => void;
sshDebugLogsEnabled: boolean;
setSshDebugLogsEnabled: (enabled: boolean) => void;
sshDeepLinkEnabled: boolean;
setSshDeepLinkEnabled: (enabled: boolean) => void;
jmsDeepLinkEnabled: boolean;
setJmsDeepLinkEnabled: (enabled: boolean) => void;
explorerContextMenuEnabled: boolean;
setExplorerContextMenuEnabled: (enabled: boolean) => void;
explorerContextMenuSupported: boolean;
restorePreviousSession: boolean;
setRestorePreviousSession: (enabled: boolean) => void;
restoreTerminalCwd: boolean;
setRestoreTerminalCwd: (enabled: boolean) => void;
startupLanding: "vault" | "local-terminal";
setStartupLanding: (landing: "vault" | "local-terminal") => void;
toggleWindowHotkey: string;
setToggleWindowHotkey: (hotkey: string) => void;
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
autoLaunchEnabled: boolean;
setAutoLaunchEnabled: (enabled: boolean) => void;
autoLaunchSupported: boolean;
httpNetworkProxy: HttpNetworkProxySettings;
setHttpNetworkProxy: (settings: HttpNetworkProxySettings | ((prev: HttpNetworkProxySettings) => HttpNetworkProxySettings)) => void;
hotkeyRegistrationError: string | null;
globalHotkeyEnabled: boolean;
setGlobalHotkeyEnabled: (enabled: boolean) => void;
autoUpdateEnabled: boolean;
setAutoUpdateEnabled: (enabled: boolean) => void;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
startDownload: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockDisable,
requestAppLockPasswordChange,
appLockSystemUnlockStatus,
setAppLockSystemUnlockEnabled,
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sessionLogsTimestampsEnabled,
setSessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
sshDeepLinkEnabled,
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
explorerContextMenuEnabled,
setExplorerContextMenuEnabled,
explorerContextMenuSupported,
restorePreviousSession,
setRestorePreviousSession,
restoreTerminalCwd,
setRestoreTerminalCwd,
startupLanding,
setStartupLanding,
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoLaunchEnabled,
setAutoLaunchEnabled,
autoLaunchSupported,
httpNetworkProxy,
setHttpNetworkProxy,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
autoUpdateEnabled,
setAutoUpdateEnabled,
updateState,
checkNow,
installUpdate,
openReleasePage,
startDownload,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
useEffect(() => {
const promise = netcattyBridge.get()?.getAppInfo?.();
if (promise) {
promise.then((info) => {
setAppVersion(info?.version ?? '');
}).catch(() => {});
}
}, []);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
setIsLoading(true);
try {
const info = await bridge.getTempDirInfo();
setTempDirInfo(info);
} catch (err) {
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTempDirInfo();
}, [loadTempDirInfo]);
const loadCredentialProtectionStatus = useCallback(async () => {
setIsCheckingCredentials(true);
try {
const available = await getCredentialProtectionAvailability();
setCredentialsAvailable(available);
} finally {
setIsCheckingCredentials(false);
}
}, []);
useEffect(() => {
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const loadCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getCrashLogs) return;
setIsLoadingCrashLogs(true);
try {
const logs = await bridge.getCrashLogs();
setCrashLogs(logs);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
} finally {
setIsLoadingCrashLogs(false);
}
}, []);
useEffect(() => {
void loadCrashLogs();
}, [loadCrashLogs]);
const loadSshDebugLogInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getSshDebugLogInfo) return;
setIsLoadingSshDebugLogInfo(true);
try {
const info = await bridge.getSshDebugLogInfo();
setSshDebugLogInfo(info);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load SSH debug log info:", err);
} finally {
setIsLoadingSshDebugLogInfo(false);
}
}, []);
useEffect(() => {
void loadSshDebugLogInfo();
}, [loadSshDebugLogInfo, sshDebugLogsEnabled]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
setExpandedLog(null);
setLogEntries([]);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.readCrashLog) return;
const requestId = ++expandRequestRef.current;
// Optimistically show expanded state while loading
setExpandedLog(fileName);
setLogEntries([]);
try {
const entries = await bridge.readCrashLog(fileName);
// Discard if user clicked a different file while awaiting
if (expandRequestRef.current !== requestId) return;
setLogEntries(entries);
} catch (err) {
if (expandRequestRef.current !== requestId) return;
console.error("[SettingsSystemTab] Failed to read crash log:", err);
}
}, [expandedLog]);
const handleClearCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearCrashLogs) return;
setIsClearingCrashLogs(true);
setCrashLogClearResult(null);
try {
const result = await bridge.clearCrashLogs();
setCrashLogClearResult(result);
setExpandedLog(null);
setLogEntries([]);
// Reload the list so partial failures still show remaining files
await loadCrashLogs();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
} finally {
setIsClearingCrashLogs(false);
}
}, [loadCrashLogs]);
const handleOpenCrashLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openCrashLogsDir) return;
await bridge.openCrashLogsDir();
}, []);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
setIsClearing(true);
setClearResult(null);
try {
const result = await bridge.clearTempDir();
setClearResult(result);
// Refresh info after clearing
await loadTempDirInfo();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear temp dir:", err);
} finally {
setIsClearing(false);
}
}, [loadTempDirInfo]);
const handleOpenTempDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
await bridge.openTempDir();
}, [tempDirInfo]);
const handleSelectSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectSessionLogsDir) return;
try {
const result = await bridge.selectSessionLogsDir();
if (result.success && result.directory) {
setSessionLogsDir(result.directory);
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to select directory:", err);
}
}, [setSessionLogsDir]);
const handleOpenSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
try {
await bridge.openSessionLogsDir(sessionLogsDir);
} catch (err) {
console.error("[SettingsSystemTab] Failed to open directory:", err);
}
}, [sessionLogsDir]);
const handleClearSessionLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.clearSessionLogsDir) return;
if (!window.confirm(t("settings.sessionLogs.clearConfirm"))) return;
setIsClearingSessionLogs(true);
setSessionLogsClearResult(null);
try {
const result = await bridge.clearSessionLogsDir(sessionLogsDir);
if (result.success) {
setSessionLogsClearResult({ deletedCount: result.deletedCount, failedCount: result.failedCount });
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear session logs:", err);
} finally {
setIsClearingSessionLogs(false);
}
}, [sessionLogsDir, t]);
const handleOpenSshDebugLogDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openSshDebugLogDir) return;
await bridge.openSshDebugLogDir();
}, []);
// Handle global toggle hotkey recording
const cancelHotkeyRecording = useCallback(() => {
setIsRecordingHotkey(false);
}, []);
const handleResetHotkey = useCallback(() => {
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
setToggleWindowHotkey(defaultHotkey);
setHotkeyError(null);
}, [isMac, setToggleWindowHotkey]);
// Hotkey recording effect
useEffect(() => {
if (!isRecordingHotkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (isAppLockOverlayActive()) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
cancelHotkeyRecording();
return;
}
// Ignore modifier-only keys
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const keyString = keyEventToString(e, isMac);
setToggleWindowHotkey(keyString);
setHotkeyError(null);
cancelHotkeyRecording();
};
const handleClick = () => {
cancelHotkeyRecording();
};
const timer = setTimeout(() => {
window.addEventListener("click", handleClick, true);
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("click", handleClick, true);
};
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
const formatOptions = [
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
];
return (
<SettingsTabContent value="system">
<SectionHeader title={t('settings.update.title')} anchorId="system-update" />
<SettingCard className="space-y-3 py-4">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">
{updateState.currentVersion || appVersion || '...'}
</span>
</div>
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
{updateState.autoDownloadStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updateState.downloadPercent}%` }}
/>
</div>
</div>
)}
{updateState.autoDownloadStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateState.autoDownloadStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.downloadError || t('settings.update.error')}
</p>
)}
{updateState.autoDownloadStatus === 'idle' && (
<>
{updateState.manualCheckStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace(
'{version}',
updateState.latestRelease?.version ?? ''
)}
</p>
)}
{updateState.manualCheckStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.error || t('settings.update.error')}
</p>
)}
</>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
/* Check button — shown in idle states and in error state (allows retry) */
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
>
<RefreshCw size={14} className="mr-1.5" />
{t('settings.update.checkForUpdates')}
</Button>
) : null}
{/* Install button — shown when download is complete */}
{updateState.autoDownloadStatus === 'ready' && (
<Button variant="default" size="sm" onClick={installUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Open releases — shown on download error */}
{updateState.autoDownloadStatus === 'error' && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
{/* Download button — shown when update found and no download in progress */}
{updateState.autoDownloadStatus === 'idle' &&
updateState.manualCheckStatus === 'available' && (
<Button variant="outline" size="sm" onClick={startDownload}>
<Download size={14} className="mr-1.5" />
{t('update.downloadNow')}
</Button>
)}
{/* Open releases — fallback for unsupported platforms or check errors */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
</div>
<SettingRow
anchorId="system-auto-update"
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
</SettingCard>
<SettingHint>
{updateState.lastCheckedAt && (
<span>
{t('settings.update.lastCheckedPrefix')}
{formatLastChecked(updateState.lastCheckedAt, t)}
{' '}
</span>
)}
{t('settings.update.hint')}
</SettingHint>
<SectionHeader title={t("settings.autoLaunch.title")} />
<SettingCard className="py-4">
<SettingRow
anchorId="system-auto-launch"
label={t("settings.autoLaunch.enabled")}
description={
autoLaunchSupported
? t("settings.autoLaunch.enabledDesc")
: t("settings.autoLaunch.unsupportedDesc")
}
>
<Toggle
checked={autoLaunchEnabled}
onChange={setAutoLaunchEnabled}
disabled={!autoLaunchSupported}
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t("settings.system.networkProxy.title")} />
<SettingCard className="space-y-4 py-4">
<SettingRow
anchorId="system-network-proxy-mode"
label={t("settings.system.networkProxy.mode")}
description={t("settings.system.networkProxy.description")}
>
<Select
value={httpNetworkProxy.mode}
onChange={(value) => {
const mode = value as HttpNetworkProxyMode;
setHttpNetworkProxy((prev) => ({ ...prev, mode }));
}}
options={[
{ value: "system", label: t("settings.system.networkProxy.mode.system") },
{ value: "direct", label: t("settings.system.networkProxy.mode.direct") },
{ value: "custom", label: t("settings.system.networkProxy.mode.custom") },
]}
/>
</SettingRow>
{httpNetworkProxy.mode === "custom" && (
<>
<SettingRow
label={t("settings.system.networkProxy.url")}
description={t("settings.system.networkProxy.url.desc")}
>
<input
type="text"
value={httpNetworkProxy.url}
onChange={(e) => {
const url = e.target.value;
setHttpNetworkProxy((prev) => ({ ...prev, url }));
}}
placeholder={t("settings.system.networkProxy.url.placeholder")}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
spellCheck={false}
autoComplete="off"
/>
</SettingRow>
<SettingRow
label={t("settings.system.networkProxy.bypass")}
description={t("settings.system.networkProxy.bypass.desc")}
>
<input
type="text"
value={httpNetworkProxy.bypass}
onChange={(e) => {
const bypass = e.target.value;
setHttpNetworkProxy((prev) => ({ ...prev, bypass }));
}}
placeholder={t("settings.system.networkProxy.bypass.placeholder")}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
spellCheck={false}
autoComplete="off"
/>
</SettingRow>
</>
)}
</SettingCard>
<SettingHint>
{t("settings.system.networkProxy.hint")}
</SettingHint>
<AppLockSettingsSection
appLockSettings={appLockSettings}
setAppLockTimeoutMinutes={setAppLockTimeoutMinutes}
requestAppLockDisable={requestAppLockDisable}
requestAppLockPasswordChange={requestAppLockPasswordChange}
appLockSystemUnlockStatus={appLockSystemUnlockStatus}
setAppLockSystemUnlockEnabled={setAppLockSystemUnlockEnabled}
/>
<SectionHeader title={t("settings.system.credentials.title")} />
<SettingsAnchor anchorId="system-credentials">
<SettingCard className="space-y-3 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
{t("settings.system.credentials.status")}
</p>
<p
className={cn(
"text-sm font-medium mt-1",
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
)}
>
{isCheckingCredentials
? t("settings.system.credentials.checking")
: credentialsAvailable === true
? t("settings.system.credentials.available")
: credentialsAvailable === false
? t("settings.system.credentials.unavailable")
: t("settings.system.credentials.unknown")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadCredentialProtectionStatus}
disabled={isCheckingCredentials}
className="gap-1.5"
>
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
</div>
{credentialsAvailable === false && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("settings.system.credentials.unavailableHint")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</SettingCard>
</SettingsAnchor>
<SectionHeader title={t("settings.system.crashLogs.title")} />
<SettingsAnchor anchorId="system-crash-logs">
<SettingCard className="space-y-3 py-4">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="text-muted-foreground truncate cursor-default">
{text}
</div>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</SettingCard>
<SettingHint>
{t("settings.system.crashLogs.hint")}
</SettingHint>
</SettingsAnchor>
<SectionHeader title={t("settings.system.tempDirectory")} />
<SettingsAnchor anchorId="system-temp-directory">
<SettingCard className="space-y-3 py-4">
{/* Path */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground">{t("settings.system.location")}</p>
<p className="text-sm font-mono mt-1 break-all">
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-muted-foreground">{t("settings.system.fileCount")}:</span>{" "}
<span className="font-medium">
{isLoading ? "..." : (tempDirInfo?.fileCount ?? 0)}
</span>
</div>
<div>
<span className="text-muted-foreground">{t("settings.system.totalSize")}:</span>{" "}
<span className="font-medium">
{isLoading ? "..." : formatBytes(tempDirInfo?.totalSize ?? 0)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadTempDirInfo}
disabled={isLoading}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearTempFiles}
disabled={isClearing || (tempDirInfo?.fileCount ?? 0) === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{isClearing ? t("settings.system.clearing") : t("settings.system.clearTempFiles")}
</Button>
</div>
{/* Clear Result */}
{clearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.clearResult", {
deleted: clearResult.deletedCount,
failed: clearResult.failedCount,
})}
</p>
)}
</SettingCard>
<SettingHint>
{t("settings.system.tempDirectoryHint")}
</SettingHint>
</SettingsAnchor>
<SectionHeader title={t("settings.sessionRestore.title")} />
<SettingCard className="space-y-4 py-4">
<SettingRow
anchorId="system-startup-landing"
label={t("settings.sessionRestore.startupLanding")}
description={t("settings.sessionRestore.startupLandingDesc")}
>
<Select
value={startupLanding}
onChange={(value) => {
if (value === "vault" || value === "local-terminal") {
setStartupLanding(value);
}
}}
options={[
{ value: "vault", label: t("settings.sessionRestore.startupLanding.vault") },
{ value: "local-terminal", label: t("settings.sessionRestore.startupLanding.localTerminal") },
]}
/>
</SettingRow>
<SettingRow
anchorId="system-session-restore"
label={t("settings.sessionRestore.restorePreviousSession")}
description={t("settings.sessionRestore.restorePreviousSessionDesc")}
>
<Toggle
checked={restorePreviousSession}
onChange={setRestorePreviousSession}
/>
</SettingRow>
<SettingRow
anchorId="system-restore-terminal-cwd"
label={t("settings.sessionRestore.restoreTerminalCwd")}
description={t("settings.sessionRestore.restoreTerminalCwdDesc")}
>
<Toggle
checked={restoreTerminalCwd}
onChange={setRestoreTerminalCwd}
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t("settings.sessionLogs.title")} />
<SettingCard className="space-y-4 py-4">
{/* Enable Toggle */}
<SettingRow
anchorId="system-session-logs-enable"
label={t("settings.sessionLogs.enableAutoSave")}
description={t("settings.sessionLogs.enableAutoSaveDesc")}
>
<Toggle
checked={sessionLogsEnabled}
onChange={setSessionLogsEnabled}
/>
</SettingRow>
{/* Directory Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectSessionLogsDir}
className="shrink-0"
>
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.directoryHint")}
</p>
</div>
{/* Format Selection */}
<SettingRow
label={t("settings.sessionLogs.format")}
description={t("settings.sessionLogs.formatDesc")}
>
<Select
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-44"
/>
</SettingRow>
<SettingRow
label={t("settings.sessionLogs.timestamps")}
description={t("settings.sessionLogs.timestampsDesc")}
>
<Toggle
checked={sessionLogsTimestampsEnabled}
onChange={setSessionLogsTimestampsEnabled}
/>
</SettingRow>
{/* Clear All Logs */}
<div className="space-y-2 pt-2 border-t border-border/60">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{t("settings.sessionLogs.clearAll")}</p>
<p className="text-xs text-muted-foreground">{t("settings.sessionLogs.clearAllDesc")}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleClearSessionLogs}
disabled={isClearingSessionLogs || !sessionLogsDir}
className="gap-1.5 shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{isClearingSessionLogs ? t("settings.system.clearing") : t("settings.sessionLogs.clearAll")}
</Button>
</div>
{sessionLogsClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.clearResult", {
deleted: sessionLogsClearResult.deletedCount,
failed: sessionLogsClearResult.failedCount,
})}
</p>
)}
</div>
</SettingCard>
<SettingHint>
{t("settings.sessionLogs.hint")}
</SettingHint>
<SectionHeader title={t('settings.sshDeepLink.title')} />
<SettingCard>
<SettingRow
anchorId="system-ssh-deep-link"
label={t('settings.sshDeepLink.enable')}
description={t('settings.sshDeepLink.enableDesc')}
>
<Toggle
checked={sshDeepLinkEnabled}
onChange={setSshDeepLinkEnabled}
ariaLabel={t('settings.sshDeepLink.enable')}
/>
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.jmsDeepLink.title')} />
<SettingCard>
<SettingRow
anchorId="system-jms-deep-link"
label={t('settings.jmsDeepLink.enable')}
description={t('settings.jmsDeepLink.enableDesc')}
>
<Toggle
checked={jmsDeepLinkEnabled}
onChange={setJmsDeepLinkEnabled}
ariaLabel={t('settings.jmsDeepLink.enable')}
/>
</SettingRow>
</SettingCard>
{explorerContextMenuSupported ? (
<>
<SectionHeader title={t('settings.explorerContextMenu.title')} />
<SettingCard>
<SettingRow
anchorId="system-explorer-context-menu"
label={t('settings.explorerContextMenu.enable')}
description={t('settings.explorerContextMenu.enableDesc')}
>
<Toggle
checked={explorerContextMenuEnabled}
onChange={setExplorerContextMenuEnabled}
ariaLabel={t('settings.explorerContextMenu.enable')}
/>
</SettingRow>
</SettingCard>
</>
) : (
<SettingsAnchor anchorId="system-explorer-context-menu" />
)}
<SectionHeader title={t("settings.sshDebugLogs.title")} />
<SettingCard className="min-w-0 max-w-full overflow-hidden space-y-4 py-4">
<SettingRow
anchorId="system-ssh-debug-logs"
label={t("settings.sshDebugLogs.enable")}
description={t("settings.sshDebugLogs.enableDesc")}
>
<Toggle
checked={sshDebugLogsEnabled}
onChange={setSshDebugLogsEnabled}
/>
</SettingRow>
<div className="space-y-2">
<span className="text-sm font-medium">{t("settings.sshDebugLogs.location")}</span>
<div className="grid w-full min-w-0 max-w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
<div className="min-w-0 overflow-hidden">
<div
className="w-full min-w-0 overflow-hidden truncate rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
title={isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
>
{isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={loadSshDebugLogInfo}
disabled={isLoadingSshDebugLogInfo}
className="shrink-0 gap-1.5"
>
<RefreshCw size={14} className={isLoadingSshDebugLogInfo ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenSshDebugLogDir}
className="shrink-0"
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{t("settings.sshDebugLogs.status")}:{" "}
{sshDebugLogsEnabled ? t("settings.sshDebugLogs.statusOn") : t("settings.sshDebugLogs.statusOff")}
</span>
<span>
{t("settings.sshDebugLogs.size")}: {formatBytes(sshDebugLogInfo?.size ?? 0)}
</span>
</div>
</div>
</SettingCard>
<SettingHint>
{t("settings.sshDebugLogs.hint")}
</SettingHint>
<SectionHeader title={t("settings.globalHotkey.title")} />
<SettingCard className="space-y-4 py-4">
{/* Enable/Disable Global Hotkey */}
<SettingRow
anchorId="system-global-hotkey-enabled"
label={t('settings.globalHotkey.enabled')}
description={t('settings.globalHotkey.enabledDesc')}
>
<Toggle
checked={globalHotkeyEnabled}
onChange={setGlobalHotkeyEnabled}
/>
</SettingRow>
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
{/* Toggle Window Hotkey */}
<SettingRow
anchorId="system-global-hotkey-toggle"
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
>
<RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
</Tooltip>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
)}
</div>
{/* Close to Tray */}
<SettingRow
anchorId="system-close-to-tray"
label={t("settings.globalHotkey.closeToTray")}
description={t("settings.globalHotkey.closeToTrayDesc")}
>
<Toggle
checked={closeToTray}
onChange={setCloseToTray}
/>
</SettingRow>
</SettingCard>
<SettingHint>
{t("settings.globalHotkey.hint")}
</SettingHint>
</SettingsTabContent>
);
};
export default React.memo(SettingsSystemTab);