Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1326 lines
54 KiB
TypeScript
1326 lines
54 KiB
TypeScript
/**
|
||
* 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);
|