diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index e55bd8a..38c816e 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -17,9 +17,9 @@ * node scripts/bump-version.mjs 0.3.0-alpha # explicit version */ -import { readFileSync, writeFileSync } from "fs"; -import { resolve, dirname } from "path"; -import { fileURLToPath } from "url"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -39,9 +39,9 @@ function parseVersion(version) { const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); if (!match) throw new Error(`Invalid version: ${version}`); return { - major: parseInt(match[1]), - minor: parseInt(match[2]), - patch: parseInt(match[3]), + major: Number.parseInt(match[1]), + minor: Number.parseInt(match[2]), + patch: Number.parseInt(match[3]), prerelease: match[4] || null, }; } diff --git a/src/App.tsx b/src/App.tsx index 6345c1a..6eb9e8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,9 @@ import Modal from "./components/Modal"; import { SshConnectionConfig } from "./types"; import { CommandPalette, useCommandPalette, CommandHandlers, CommandContext } from "./components/CommandPalette"; import { StatusBar, type StatusBarItem } from "./components/StatusBar"; -import { pluginManager, type SessionInfo, type ModalConfig, type NotificationType, type PromptConfig } from "./plugins"; +import { pluginManager, type SessionInfo, type ModalConfig, type NotificationType, type PromptConfig, type HeaderActionItem } from "./plugins"; const PluginHost = lazy(() => import("./plugins/PluginHost").then(m => ({ default: m.PluginHost }))); // plugin-updater is lazy-imported in the auto-check useEffect below -import type { HeaderActionItem } from "./plugins"; const SftpBrowser = lazy(() => import("./components/SftpBrowser").then(m => ({ default: m.SftpBrowser }))); const TunnelManager = lazy(() => import("./components/TunnelManager")); const TunnelSidebar = lazy(() => import("./components/TunnelSidebar")); @@ -30,13 +29,38 @@ const VaultSetupModal = lazy(() => import("./components/Vault/VaultSetupModal")) const VaultUnlockModal = lazy(() => import("./components/Vault/VaultUnlockModal")); import { useSessions, useAppSettings, useVaultFlow, useHostKeyVerification, useWorkspace } from "./hooks"; import type { SshConnectionResult } from "./hooks"; -import { SavedSession, TelnetConnectionConfig, SerialConnectionConfig, SshKeyProfile, ConnectionType } from "./types"; -import type { PaneGroupTab } from "./types"; +import { SavedSession, TelnetConnectionConfig, SerialConnectionConfig, SshKeyProfile, ConnectionType, type PaneGroupTab } from "./types"; import { generateSessionId, expandHomeDir, isModifierPressed } from "./utils"; import { applyTheme } from "./themes"; const noop = () => {}; +interface ShortcutEntry { + mod?: boolean; + ctrl?: boolean; + shift: boolean; + skipInput?: boolean; + action: () => void; +} + +function matchesShortcut(entry: ShortcutEntry, mod: boolean, e: KeyboardEvent, isInput: boolean): boolean { + if (entry.mod && !mod) return false; + if (entry.ctrl && !e.ctrlKey) return false; + if (entry.shift !== e.shiftKey) return false; + if (entry.skipInput && isInput) return false; + return true; +} + +function getConnectionModalTitle( + editingSessionId: string | null, + pendingSftpSession: unknown, + t: (key: string) => string, +): string | undefined { + if (editingSessionId) return t('app.editConnection'); + if (pendingSftpSession) return t('app.sftpConnection'); + return undefined; +} + function App() { const { t } = useTranslation(); @@ -93,7 +117,7 @@ function App() { const handleToggleSidebarPin = useCallback(() => { const newPinned = !sidebarPinned; - void handleSettingsChange({ + handleSettingsChange({ ...appSettings, ui: { ...appSettings.ui, sidebarPinned: newPinned }, }); @@ -320,44 +344,80 @@ function App() { } }; - const handleSshConnect = async (config: SshConnectionConfig) => { - setIsConnecting(true); - setConnectionError(undefined); + const handleSftpConnectFromPending = async (config: SshConnectionConfig) => { + const saved = pendingSftpSession!; + setPendingSftpSession(null); - // Check if this is for SFTP (credentials were requested for SFTP) - if (pendingSftpSession) { - const saved = pendingSftpSession; - setPendingSftpSession(null); + try { + const sessionId = generateSessionId("sftp"); + const keyPath = await expandHomeDir(config.keyPath); - try { - const sessionId = generateSessionId("sftp"); - const keyPath = await expandHomeDir(config.keyPath); + await invoke("register_sftp_session", { + sessionId, + host: config.host, + port: config.port, + username: config.username, + password: config.authType === "password" ? config.password : null, + keyPath: config.authType === "key" ? keyPath : null, + keyPassphrase: config.authType === "key" ? config.keyPassphrase : null, + }); + + workspace.addTabToFocusedGroup({ + type: "sftp", + title: `SFTP - ${saved.name}`, + sessionId, + sshConfig: config, + }); - await invoke("register_sftp_session", { - sessionId, + setIsConnectionModalOpen(false); + setOpenSidebar("none"); + setIsConnecting(false); + setInitialConnectionConfig(null); + + try { + await invoke("save_session", { + id: saved.id, + name: config.name, host: config.host, port: config.port, username: config.username, - password: config.authType === "password" ? config.password : null, - keyPath: config.authType === "key" ? keyPath : null, - keyPassphrase: config.authType === "key" ? config.keyPassphrase : null, - }); - - workspace.addTabToFocusedGroup({ - type: "sftp", - title: `SFTP - ${saved.name}`, - sessionId, - sshConfig: config, + authType: config.authType, + keyPath: config.keyPath, + password: config.password, + keyPassphrase: config.keyPassphrase, + sshKeyId: config.sshKeyId || null, }); + await loadSavedSessions(); + } catch (err) { + console.error("[SFTP] Failed to save credentials:", err); + } + } catch (error) { + console.error("Failed to open SFTP:", error); + setConnectionError(t('app.sftpError', { error })); + setIsConnecting(false); + } + }; - setIsConnectionModalOpen(false); - setOpenSidebar("none"); - setIsConnecting(false); - setInitialConnectionConfig(null); + const buildJumpHostParams = (config: SshConnectionConfig, jumpKeyPath: string | undefined) => { + if (!config.useJumpHost) { + return { jumpHost: null, jumpPort: null, jumpUsername: null, jumpPassword: null, jumpKeyPath: null, jumpKeyPassphrase: null }; + } + return { + jumpHost: config.jumpHost, + jumpPort: config.jumpPort, + jumpUsername: config.jumpUsername || config.username, + jumpPassword: config.jumpAuthType === "password" ? config.jumpPassword : null, + jumpKeyPath: config.jumpAuthType === "key" ? jumpKeyPath : null, + jumpKeyPassphrase: config.jumpAuthType === "key" ? config.jumpKeyPassphrase : null, + }; + }; + const saveSessionAfterConnect = (config: SshConnectionConfig) => { + if (editingSessionId) { + (async () => { try { await invoke("save_session", { - id: saved.id, + id: editingSessionId, name: config.name, host: config.host, port: config.port, @@ -370,114 +430,90 @@ function App() { }); await loadSavedSessions(); } catch (err) { - console.error("[SFTP] Failed to save credentials:", err); + console.error("[SavedSession] Failed to save credentials:", err); } - return; - } catch (error) { - console.error("Failed to open SFTP:", error); - setConnectionError(t('app.sftpError', { error })); - setIsConnecting(false); - return; + })(); + setEditingSessionId(null); + } else { + const isAlreadySaved = savedSessions.some( + (s) => s.host === config.host && s.username === config.username && s.port === config.port + ); + if (!isAlreadySaved) { + setPendingSaveConfig(config); + setIsSaveModalOpen(true); } } + }; - // Normal SSH connection — single TCP connection (host key check built-in) - { - const ptySessionId = generateSessionId("ssh"); + const handleSshConnect = async (config: SshConnectionConfig) => { + setIsConnecting(true); + setConnectionError(undefined); - try { - let resolvedKeyPath = config.keyPath; - let resolvedKeyPassphrase = config.keyPassphrase; - - if (config.sshKeyId) { - const resolved = await resolveSshKey(config.sshKeyId); - if (!resolved) { - setIsConnecting(false); - return; - } - resolvedKeyPath = resolved.keyPath; - resolvedKeyPassphrase = resolved.passphrase || undefined; + if (pendingSftpSession) { + await handleSftpConnectFromPending(config); + return; + } + + const ptySessionId = generateSessionId("ssh"); + + try { + let resolvedKeyPath = config.keyPath; + let resolvedKeyPassphrase = config.keyPassphrase; + + if (config.sshKeyId) { + const resolved = await resolveSshKey(config.sshKeyId); + if (!resolved) { + setIsConnecting(false); + return; } + resolvedKeyPath = resolved.keyPath; + resolvedKeyPassphrase = resolved.passphrase || undefined; + } + + const keyPath = await expandHomeDir(resolvedKeyPath); + const jumpKeyPath = config.useJumpHost ? await expandHomeDir(config.jumpKeyPath) : undefined; + + const result = await invoke("create_ssh_session", { + sessionId: ptySessionId, + host: config.host, + port: config.port, + username: config.username, + password: config.password, + keyPath, + keyPassphrase: resolvedKeyPassphrase, + ...buildJumpHostParams(config, jumpKeyPath), + }); + + const onConnected = () => { + workspace.addTabToFocusedGroup({ + type: "ssh", + title: config.name, + sessionId: `ssh-${config.host}`, + ptySessionId, + sshConfig: config, + }); - const keyPath = await expandHomeDir(resolvedKeyPath); - const jumpKeyPath = config.useJumpHost ? await expandHomeDir(config.jumpKeyPath) : undefined; + setIsConnectionModalOpen(false); + setOpenSidebar("none"); + setIsConnecting(false); - const result = await invoke("create_ssh_session", { - sessionId: ptySessionId, + pluginManager.notifySessionConnect({ + id: ptySessionId, + type: 'ssh', host: config.host, port: config.port, username: config.username, - password: config.password, - keyPath, - keyPassphrase: resolvedKeyPassphrase, - jumpHost: config.useJumpHost ? config.jumpHost : null, - jumpPort: config.useJumpHost ? config.jumpPort : null, - jumpUsername: config.useJumpHost ? (config.jumpUsername || config.username) : null, - jumpPassword: config.useJumpHost && config.jumpAuthType === "password" ? config.jumpPassword : null, - jumpKeyPath: config.useJumpHost && config.jumpAuthType === "key" ? jumpKeyPath : null, - jumpKeyPassphrase: config.useJumpHost && config.jumpAuthType === "key" ? config.jumpKeyPassphrase : null, + status: 'connected', }); - const onConnected = () => { - workspace.addTabToFocusedGroup({ - type: "ssh", - title: config.name, - sessionId: `ssh-${config.host}`, - ptySessionId, - sshConfig: config, - }); - - setIsConnectionModalOpen(false); - setOpenSidebar("none"); - setIsConnecting(false); - - pluginManager.notifySessionConnect({ - id: ptySessionId, - type: 'ssh', - host: config.host, - port: config.port, - username: config.username, - status: 'connected', - }); - - if (editingSessionId) { - (async () => { - try { - await invoke("save_session", { - id: editingSessionId, - name: config.name, - host: config.host, - port: config.port, - username: config.username, - authType: config.authType, - keyPath: config.keyPath, - password: config.password, - keyPassphrase: config.keyPassphrase, - sshKeyId: config.sshKeyId || null, - }); - await loadSavedSessions(); - } catch (err) { - console.error("[SavedSession] Failed to save credentials:", err); - } - })(); - setEditingSessionId(null); - } else { - const isAlreadySaved = savedSessions.some( - (s) => s.host === config.host && s.username === config.username && s.port === config.port - ); - if (!isAlreadySaved) { - setPendingSaveConfig(config); - setIsSaveModalOpen(true); - } - } - }; + saveSessionAfterConnect(config); + }; - handleSshConnectionResult(result, onConnected, ptySessionId); - } catch (error) { - console.error("SSH connection failed:", error); - setConnectionError(String(error)); - setIsConnecting(false); - } + handleSshConnectionResult(result, onConnected, ptySessionId); + } catch (error) { + console.error("SSH connection failed:", error); + setConnectionError(String(error)); + setIsConnecting(false); } }; @@ -783,7 +819,7 @@ function App() { const handleCloseTab = useCallback((tabId: string) => { const closedTab = workspace.closeTab(tabId); - if (closedTab && closedTab.ptySessionId) { + if (closedTab?.ptySessionId) { invoke("close_pty_session", { sessionId: closedTab.ptySessionId }).catch(console.error); pluginManager.notifySessionDisconnect(closedTab.ptySessionId); } @@ -805,69 +841,42 @@ function App() { // ============================================================================ useEffect(() => { + const closeActiveTab = () => { + const g = workspace.groups.get(workspace.focusedGroupId); + if (g?.activeTabId) handleCloseTab(g.activeTabId); + }; + + const shortcuts: Record = { + t: [{ mod: true, shift: false, action: handleNewLocalTab }], + n: [{ mod: true, shift: false, skipInput: true, action: handleOpenConnectionModal }], + w: [{ mod: true, shift: false, action: closeActiveTab }], + Tab: [ + { ctrl: true, shift: false, action: () => workspace.cycleFocusedGroupTab("next") }, + { ctrl: true, shift: true, action: () => workspace.cycleFocusedGroupTab("prev") }, + ], + ",": [{ mod: true, shift: false, action: () => workspace.openSettings() }], + D: [{ mod: true, shift: true, action: () => workspace.splitFocusedGroup("vertical") }], + E: [{ mod: true, shift: true, action: () => workspace.splitFocusedGroup("horizontal") }], + }; + const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; const isInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; const mod = isModifierPressed(e); - // Mod+T: New local terminal - if (mod && !e.shiftKey && e.key === "t") { - e.preventDefault(); - handleNewLocalTab(); - return; - } - // Mod+N: New SSH connection - if (mod && !e.shiftKey && e.key === "n") { - if (isInput) return; - e.preventDefault(); - handleOpenConnectionModal(); - return; - } - // Mod+W: Close active tab in focused group - if (mod && !e.shiftKey && e.key === "w") { - e.preventDefault(); - const focusedGroup = workspace.groups.get(workspace.focusedGroupId); - if (focusedGroup?.activeTabId) { - handleCloseTab(focusedGroup.activeTabId); - } - return; - } - // Ctrl+Tab: Next tab in focused group - if (e.ctrlKey && !e.shiftKey && e.key === "Tab") { - e.preventDefault(); - workspace.cycleFocusedGroupTab("next"); - return; - } - // Ctrl+Shift+Tab: Previous tab in focused group - if (e.ctrlKey && e.shiftKey && e.key === "Tab") { - e.preventDefault(); - workspace.cycleFocusedGroupTab("prev"); - return; - } - // Mod+,: Open settings - if (mod && !e.shiftKey && e.key === ",") { - e.preventDefault(); - workspace.openSettings(); - return; - } + const entries = shortcuts[e.key]; + if (!entries) return; - // Mod+Shift+D: Split focused group vertical - if (mod && e.shiftKey && e.key === "D") { + const match = entries.find(entry => matchesShortcut(entry, mod, e, isInput)); + if (match) { e.preventDefault(); - workspace.splitFocusedGroup("vertical"); - return; - } - // Mod+Shift+E: Split focused group horizontal - if (mod && e.shiftKey && e.key === "E") { - e.preventDefault(); - workspace.splitFocusedGroup("horizontal"); - return; + match.action(); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [workspace, handleNewLocalTab, handleCloseTab]); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); + }, [workspace, handleNewLocalTab, handleCloseTab, handleOpenConnectionModal]); // ============================================================================ // Command Palette @@ -901,8 +910,8 @@ function App() { renameTab: () => { const tab = activeTabRef.current; if (tab) { - const newName = window.prompt("Enter new tab name:", tab.title); - if (newName && newName.trim()) { + const newName = globalThis.prompt("Enter new tab name:", tab.title); + if (newName?.trim()) { workspace.renameTab(tab.id, newName.trim()); } } @@ -960,27 +969,29 @@ function App() { } }; - window.addEventListener("keydown", handleGlobalKeyDown); - return () => window.removeEventListener("keydown", handleGlobalKeyDown); + globalThis.addEventListener("keydown", handleGlobalKeyDown); + return () => globalThis.removeEventListener("keydown", handleGlobalKeyDown); }, [commandPalette.toggle]); // Auto-check for updates on startup (lazy-loads plugin-updater) useEffect(() => { - const timer = setTimeout(() => { - import("@tauri-apps/plugin-updater") - .then(({ check }) => check()) - .then((update) => { - if (update) { - setNotification({ - message: t("settings.about.updateAvailable") + ` v${update.version}`, - type: "info", - }); - if (notifTimeoutRef.current) clearTimeout(notifTimeoutRef.current); - notifTimeoutRef.current = setTimeout(() => setNotification(null), 5000); - } - }) - .catch(() => {}); - }, 3000); + const checkForUpdates = async () => { + try { + const { check } = await import("@tauri-apps/plugin-updater"); + const update = await check(); + if (update) { + setNotification({ + message: t("settings.about.updateAvailable") + ` v${update.version}`, + type: "info", + }); + if (notifTimeoutRef.current) clearTimeout(notifTimeoutRef.current); + notifTimeoutRef.current = setTimeout(() => setNotification(null), 5000); + } + } catch { + // Ignore update check failures + } + }; + const timer = setTimeout(checkForUpdates, 3000); return () => clearTimeout(timer); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -1002,8 +1013,9 @@ function App() { const handleModalButtonClick = useCallback(async (index: number) => { if (pluginModal) { - const buttons = pluginModal.config.buttons && pluginModal.config.buttons.length > 0 - ? pluginModal.config.buttons + const cfgButtons = pluginModal.config.buttons; + const buttons = cfgButtons?.length + ? cfgButtons : [{ label: "Close", variant: "secondary" as const }]; const button = buttons[index]; if (button?.onClick) { @@ -1331,7 +1343,7 @@ function App() { initialTelnetConfig={initialTelnetConfig} initialSerialConfig={initialSerialConfig} initialConnectionType={connectionType} - title={editingSessionId ? t('app.editConnection') : (pendingSftpSession ? t('app.sftpConnection') : undefined)} + title={getConnectionModalTitle(editingSessionId, pendingSftpSession, t)} /> )} diff --git a/src/components/CommandPalette/CommandPalette.tsx b/src/components/CommandPalette/CommandPalette.tsx index 440e1b6..f6538b5 100644 --- a/src/components/CommandPalette/CommandPalette.tsx +++ b/src/components/CommandPalette/CommandPalette.tsx @@ -26,7 +26,7 @@ export function CommandPalette({ onClose, onExecuteCommand, onKeyDown, -}: CommandPaletteProps) { +}: Readonly) { const { t } = useTranslation(); const inputRef = useRef(null); const listRef = useRef(null); @@ -55,19 +55,20 @@ export function CommandPalette({ {/* Overlay */} ); } diff --git a/src/components/Connection/SerialConnectionForm.tsx b/src/components/Connection/SerialConnectionForm.tsx index 07223d8..937d9e1 100644 --- a/src/components/Connection/SerialConnectionForm.tsx +++ b/src/components/Connection/SerialConnectionForm.tsx @@ -4,11 +4,16 @@ import { FormField } from "../FormField"; import { Cable, RefreshCw } from "lucide-react"; import type { SerialPortInfo } from "../../types"; +type DataBits = 5 | 6 | 7 | 8; +type StopBits = 1 | 2; +type Parity = "none" | "odd" | "even"; +type FlowControl = "none" | "hardware" | "software"; + const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]; -const DATA_BITS: (5 | 6 | 7 | 8)[] = [5, 6, 7, 8]; -const STOP_BITS: (1 | 2)[] = [1, 2]; -const PARITY_OPTIONS: ("none" | "odd" | "even")[] = ["none", "odd", "even"]; -const FLOW_CONTROL_OPTIONS: ("none" | "hardware" | "software")[] = ["none", "hardware", "software"]; +const DATA_BITS: DataBits[] = [5, 6, 7, 8]; +const STOP_BITS: StopBits[] = [1, 2]; +const PARITY_OPTIONS: Parity[] = ["none", "odd", "even"]; +const FLOW_CONTROL_OPTIONS: FlowControl[] = ["none", "hardware", "software"]; export interface SerialFormContentProps { name: string; @@ -17,14 +22,14 @@ export interface SerialFormContentProps { setPort: (v: string) => void; baudRate: number; setBaudRate: (v: number) => void; - dataBits: 5 | 6 | 7 | 8; - setDataBits: (v: 5 | 6 | 7 | 8) => void; - stopBits: 1 | 2; - setStopBits: (v: 1 | 2) => void; - parity: "none" | "odd" | "even"; - setParity: (v: "none" | "odd" | "even") => void; - flowControl: "none" | "hardware" | "software"; - setFlowControl: (v: "none" | "hardware" | "software") => void; + dataBits: DataBits; + setDataBits: (v: DataBits) => void; + stopBits: StopBits; + setStopBits: (v: StopBits) => void; + parity: Parity; + setParity: (v: Parity) => void; + flowControl: FlowControl; + setFlowControl: (v: FlowControl) => void; availablePorts: SerialPortInfo[]; isLoadingPorts: boolean; onRefreshPorts: () => void; @@ -86,7 +91,7 @@ export const SerialFormContent = memo(function SerialFormContent(props: SerialFo props.setDataBits(parseInt(e.target.value) as 5 | 6 | 7 | 8)} + onChange={(e) => props.setDataBits(Number.parseInt(e.target.value) as DataBits)} className="input-field" > {DATA_BITS.map((bits) => ( @@ -116,7 +121,7 @@ export const SerialFormContent = memo(function SerialFormContent(props: SerialFo props.setParity(e.target.value as "none" | "odd" | "even")} + onChange={(e) => props.setParity(e.target.value as Parity)} className="input-field" > {PARITY_OPTIONS.map((p) => ( @@ -147,7 +152,7 @@ export const SerialFormContent = memo(function SerialFormContent(props: SerialFo props.setPort(parseInt(e.target.value) || 23)} + onChange={(e) => props.setPort(Number.parseInt(e.target.value) || 23)} min={1} max={65535} className="input-field" diff --git a/src/components/DynamicLucideIcon.tsx b/src/components/DynamicLucideIcon.tsx index 3ef33c1..96344c0 100644 --- a/src/components/DynamicLucideIcon.tsx +++ b/src/components/DynamicLucideIcon.tsx @@ -23,7 +23,7 @@ interface DynamicLucideIconProps extends Omit { name: string; } -export default function DynamicLucideIcon({ name, ...props }: DynamicLucideIconProps) { +export default function DynamicLucideIcon({ name, ...props }: Readonly) { const IconComponent = useMemo(() => toLazyIcon(name), [name]); return ( diff --git a/src/components/EmptyPaneSessions.tsx b/src/components/EmptyPaneSessions.tsx index 6dca39f..b228a80 100644 --- a/src/components/EmptyPaneSessions.tsx +++ b/src/components/EmptyPaneSessions.tsx @@ -65,6 +65,12 @@ export default memo(function EmptyPaneSessions({ ); }); +function getSessionItemClassName(isConnecting: boolean, isDisabled: boolean): string { + if (isConnecting) return "bg-accent/10 cursor-wait"; + if (isDisabled) return "opacity-50 cursor-not-allowed"; + return "hover:bg-surface-0/30 cursor-pointer"; +} + const EmptyPaneSessionItem = memo(function EmptyPaneSessionItem({ session, isConnecting, @@ -85,13 +91,7 @@ const EmptyPaneSessionItem = memo(function EmptyPaneSessionItem({ diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 0cd0d88..f8c9a96 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -17,7 +17,7 @@ interface ModalProps { width?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"; } -function Modal({ isOpen, onClose, title, children, width = "md" }: ModalProps) { +function Modal({ isOpen, onClose, title, children, width = "md" }: Readonly) { const modalRef = useRef(null); useEffect(() => { @@ -44,6 +44,7 @@ function Modal({ isOpen, onClose, title, children, width = "md" }: ModalProps) { {/* Overlay */} {/* Results */} - {error ? ( -
-
- -
-

{t("settings.plugins.registryError")}

- {error &&

{error}

} - -
- ) : loading ? ( - - ) : plugins.length === 0 ? ( -
-
- -
-

{t("settings.plugins.noResults")}

-

{t("settings.plugins.noResultsHint")}

+ +
+ ); +} + +function BrowsePluginsResults({ + error, + loading, + plugins, + installedIds, + actionLoading, + onRefresh, + onInstall, + t, +}: Readonly<{ + error: string | null; + loading: boolean; + plugins: RegistryPlugin[]; + installedIds: Set; + actionLoading: string | null; + onRefresh: () => void; + onInstall: (plugin: RegistryPlugin) => void; + t: (key: string) => string; +}>) { + if (error) { + return ( +
+
+
- ) : ( -
- {plugins.map((plugin) => { - const isInstalled = installedIds.has(plugin.id); - return ( - onInstall(plugin)} - /> - ); - })} +

{t("settings.plugins.registryError")}

+

{error}

+ +
+ ); + } + + if (loading) { + return ; + } + + if (plugins.length === 0) { + return ( +
+
+
- )} +

{t("settings.plugins.noResults")}

+

{t("settings.plugins.noResultsHint")}

+
+ ); + } + + return ( +
+ {plugins.map((plugin) => { + const isInstalled = installedIds.has(plugin.id); + return ( + onInstall(plugin)} + /> + ); + })}
); } diff --git a/src/components/Settings/ConnectionsSettings.tsx b/src/components/Settings/ConnectionsSettings.tsx index 22ce923..eae183b 100644 --- a/src/components/Settings/ConnectionsSettings.tsx +++ b/src/components/Settings/ConnectionsSettings.tsx @@ -8,7 +8,7 @@ interface ConnectionsSettingsProps { onClearAllSessions: () => void; } -export default function ConnectionsSettings({ savedSessionsCount, onClearAllSessions }: ConnectionsSettingsProps) { +export default function ConnectionsSettings({ savedSessionsCount, onClearAllSessions }: Readonly) { const { t } = useTranslation(); const [confirmClear, setConfirmClear] = useState(false); const clearTimeoutRef = useRef>(null); diff --git a/src/components/Settings/DeveloperPluginsTab.tsx b/src/components/Settings/DeveloperPluginsTab.tsx index 9a2e115..80a5cdd 100644 --- a/src/components/Settings/DeveloperPluginsTab.tsx +++ b/src/components/Settings/DeveloperPluginsTab.tsx @@ -20,7 +20,7 @@ export default function DeveloperPluginsTab({ actionLoading, onToggle, onRefresh, -}: DeveloperPluginsTabProps) { +}: Readonly) { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); const [scanning, setScanning] = useState(false); @@ -62,7 +62,7 @@ export default function DeveloperPluginsTab({ ...settings, developer: { enabled: true, devPluginsPath: devPath }, }; - void updateSettings(updated); + updateSettings(updated); } }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -108,35 +108,66 @@ export default function DeveloperPluginsTab({
{/* Dev plugins list */} - {loading ? ( - - ) : plugins.length === 0 ? ( -
-
- -
-

- {devPath - ? t("settings.plugins.devNoPlugins") - : t("settings.plugins.devNoPath")} -

-
- ) : ( -
- - {t("settings.plugins.pluginCount", { count: plugins.length })} - - {plugins.map((plugin) => ( - onToggle(plugin)} - onUninstall={() => {}} - /> - ))} + +
+ ); +} + +function DevPluginsList({ + loading, + plugins, + devPath, + actionLoading, + onToggle, + t, +}: Readonly<{ + loading: boolean; + plugins: PluginManifest[]; + devPath: string; + actionLoading: string | null; + onToggle: (plugin: PluginManifest) => void; + t: (key: string, opts?: Record) => string; +}>) { + if (loading) { + return ; + } + + if (plugins.length === 0) { + return ( +
+
+
- )} +

+ {devPath + ? t("settings.plugins.devNoPlugins") + : t("settings.plugins.devNoPath")} +

+
+ ); + } + + return ( +
+ + {t("settings.plugins.pluginCount", { count: plugins.length })} + + {plugins.map((plugin) => ( + onToggle(plugin)} + onUninstall={() => {}} + /> + ))}
); } diff --git a/src/components/Settings/InlineVaultSetup.tsx b/src/components/Settings/InlineVaultSetup.tsx index 3e29b9d..ec4a1c6 100644 --- a/src/components/Settings/InlineVaultSetup.tsx +++ b/src/components/Settings/InlineVaultSetup.tsx @@ -10,7 +10,7 @@ interface InlineVaultSetupProps { vault: ReturnType; } -export default function InlineVaultSetup({ vault }: InlineVaultSetupProps) { +export default function InlineVaultSetup({ vault }: Readonly) { const { t } = useTranslation(); const [showForm, setShowForm] = useState(false); @@ -54,14 +54,7 @@ export default function InlineVaultSetup({ vault }: InlineVaultSetupProps) {

- {!showForm ? ( - - ) : ( + {showForm ? (
+ ) : ( + )} diff --git a/src/components/Settings/InstalledPluginsTab.tsx b/src/components/Settings/InstalledPluginsTab.tsx index b8ff0f1..9e877d2 100644 --- a/src/components/Settings/InstalledPluginsTab.tsx +++ b/src/components/Settings/InstalledPluginsTab.tsx @@ -24,7 +24,7 @@ export default function InstalledPluginsTab({ onUninstall, onUpdate, onRefresh, -}: InstalledPluginsTabProps) { +}: Readonly) { const { t } = useTranslation(); const updatableCount = updates.length; @@ -63,36 +63,73 @@ export default function InstalledPluginsTab({ {/* Plugin list */} - {loading ? ( - - ) : plugins.length === 0 ? ( -
-
- -
-

{t("settings.plugins.noPlugins")}

-

- {t("settings.plugins.pluginDirHint")} -

-
- ) : ( -
- {plugins.map((plugin) => { - const update = updates.find((u) => u.id === plugin.id); - return ( - onToggle(plugin)} - onUninstall={() => onUninstall(plugin)} - onUpdate={update ? () => onUpdate(update) : undefined} - /> - ); - })} + +
+ ); +} + +function InstalledPluginsList({ + loading, + plugins, + updates, + actionLoading, + onToggle, + onUninstall, + onUpdate, + t, +}: Readonly<{ + loading: boolean; + plugins: PluginManifest[]; + updates: PluginUpdate[]; + actionLoading: string | null; + onToggle: (plugin: PluginManifest) => void; + onUninstall: (plugin: PluginManifest) => void; + onUpdate: (update: PluginUpdate) => void; + t: (key: string, opts?: Record) => string; +}>) { + if (loading) { + return ; + } + + if (plugins.length === 0) { + return ( +
+
+
- )} +

{t("settings.plugins.noPlugins")}

+

+ {t("settings.plugins.pluginDirHint")} +

+
+ ); + } + + return ( +
+ {plugins.map((plugin) => { + const update = updates.find((u) => u.id === plugin.id); + return ( + onToggle(plugin)} + onUninstall={() => onUninstall(plugin)} + onUpdate={update ? () => onUpdate(update) : undefined} + /> + ); + })}
); } diff --git a/src/components/Settings/PasswordChangeSection.tsx b/src/components/Settings/PasswordChangeSection.tsx index 49de9ce..db75c03 100644 --- a/src/components/Settings/PasswordChangeSection.tsx +++ b/src/components/Settings/PasswordChangeSection.tsx @@ -11,7 +11,7 @@ interface PasswordChangeSectionProps { vault: ReturnType; } -export default function PasswordChangeSection({ vault }: PasswordChangeSectionProps) { +export default function PasswordChangeSection({ vault }: Readonly) { const { t } = useTranslation(); const [passwordSuccess, triggerPasswordSuccess] = useAutoHideSuccess(); @@ -47,15 +47,7 @@ export default function PasswordChangeSection({ vault }: PasswordChangeSectionPr return ( - {!showForm ? ( - - ) : ( + {showForm ? (
+ ) : ( + )}
); diff --git a/src/components/Settings/PluginCards.tsx b/src/components/Settings/PluginCards.tsx index 86c75f2..feee420 100644 --- a/src/components/Settings/PluginCards.tsx +++ b/src/components/Settings/PluginCards.tsx @@ -14,6 +14,12 @@ import { import type { PluginManifest } from "../../plugins"; import type { RegistryPlugin, PluginUpdate } from "../../hooks/useRegistry"; +function getPluginStatusClassName(status: string | undefined): string { + if (status === "error") return "bg-error/15 text-error"; + if (status === "enabled") return "bg-accent/15 text-accent"; + return "bg-surface-0/40 text-text-muted"; +} + export function InstalledPluginCard({ plugin, update, @@ -21,23 +27,19 @@ export function InstalledPluginCard({ onToggle, onUninstall, onUpdate, -}: { +}: Readonly<{ plugin: PluginManifest; update?: PluginUpdate; loading: boolean; onToggle: () => void; onUninstall: () => void; onUpdate?: () => void; -}) { +}>) { const { t } = useTranslation(); return (
-
+
@@ -122,12 +124,12 @@ export function BrowsePluginCard({ isInstalled, loading, onInstall, -}: { +}: Readonly<{ plugin: RegistryPlugin; isInstalled: boolean; loading: boolean; onInstall: () => void; -}) { +}>) { const { t } = useTranslation(); return ( @@ -203,11 +205,11 @@ export function BrowsePluginCard({ ); } -export function PluginListSkeleton({ count = 3 }: { count?: number }) { +export function PluginListSkeleton({ count = 3 }: Readonly<{ count?: number }>) { return (
- {Array.from({ length: count }).map((_, i) => ( -
+ {Array.from({ length: count }, (_, i) => i).map(n => ( +
diff --git a/src/components/Settings/PluginsSettings.tsx b/src/components/Settings/PluginsSettings.tsx index 3f1d5c1..6006d12 100644 --- a/src/components/Settings/PluginsSettings.tsx +++ b/src/components/Settings/PluginsSettings.tsx @@ -27,12 +27,12 @@ export default function PluginsSettings() { const installedIds = new Set(plugins.filter((p) => !p.isDev).map((p) => p.id)); useEffect(() => { - void registry.checkUpdates(); + registry.checkUpdates(); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (tab === "browse" && registry.plugins.length === 0 && !registry.loading) { - void registry.fetchPlugins(); + registry.fetchPlugins(); } }, [tab]); // eslint-disable-line react-hooks/exhaustive-deps @@ -118,9 +118,9 @@ export default function PluginsSettings() { (query: string) => { setSearchQuery(query); if (query.trim()) { - void registry.searchPlugins(query); + registry.searchPlugins(query); } else { - void registry.fetchPlugins(); + registry.fetchPlugins(); } }, [registry] // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/components/Settings/SettingsTab.tsx b/src/components/Settings/SettingsTab.tsx index 66df9a5..083a38d 100644 --- a/src/components/Settings/SettingsTab.tsx +++ b/src/components/Settings/SettingsTab.tsx @@ -1,9 +1,7 @@ import { useState, useEffect, useMemo, useCallback, memo, lazy, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { Palette, Terminal, Link2, Info, Shield, Puzzle } from "lucide-react"; -import { pluginManager } from "../../plugins"; -import { PluginSettingsPanel } from "../../plugins"; -import type { SettingsPanelRegistration } from "../../plugins"; +import { pluginManager, PluginSettingsPanel, type SettingsPanelRegistration } from "../../plugins"; import type { AppSettings } from "../../types"; import { AppearanceSettings, @@ -30,11 +28,11 @@ const SettingsSectionButton = memo(function SettingsSectionButton({ section, isActive, onSelect, -}: { +}: Readonly<{ section: { id: SettingsSection; label: string; icon: React.ReactNode }; isActive: boolean; onSelect: (id: SettingsSection) => void; -}) { +}>) { const handleClick = useCallback(() => onSelect(section.id), [section.id, onSelect]); return ( - ) : ( + {showDeleteConfirm ? (

{t("settings.security.deleteVaultWarning")} @@ -143,6 +140,14 @@ export default function VaultStatusSection({ vault }: VaultStatusSectionProps) {

+ ) : ( + )} diff --git a/src/components/SftpBrowser.tsx b/src/components/SftpBrowser.tsx index 99f1231..0a910b4 100644 --- a/src/components/SftpBrowser.tsx +++ b/src/components/SftpBrowser.tsx @@ -46,7 +46,19 @@ interface FileUploadedEvent { error?: string; } -export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) { +function getRowHighlight(entryPath: string, contextMenuPath?: string, selectedPath?: string): string { + if (contextMenuPath === entryPath) return "bg-blue/20 ring-1 ring-blue/40 ring-inset"; + if (selectedPath === entryPath) return "bg-blue/10"; + return "hover:bg-surface-0/30"; +} + +function getEditIndicator(status: string | undefined): { className: string; title: string } { + if (status === "uploading") return { className: "bg-yellow animate-pulse", title: "Uploading..." }; + if (status === "error") return { className: "bg-red", title: "Upload failed" }; + return { className: "bg-teal", title: "Watching for changes" }; +} + +export function SftpBrowser({ sessionId, initialPath = "/" }: Readonly) { const [currentPath, setCurrentPath] = useState(initialPath); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -72,7 +84,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) } | null>(null); const isMountedRef = useRef(true); - useEffect(() => { return () => { isMountedRef.current = false; }; }, []); + useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const loadDirectory = useCallback(async (path: string) => { setLoading(true); @@ -94,7 +106,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) }, [sessionId]); useEffect(() => { - void loadDirectory(currentPath); + loadDirectory(currentPath); }, []); // Listen for file upload events @@ -103,45 +115,36 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) let unlistenFn: (() => void) | null = null; const pendingTimeouts = new Set>(); - (async () => { - const unlisten = await listen("sftp-file-uploaded", (event) => { - if (!isMounted) return; - const { session_id, remote_path, success, error } = event.payload; - - // Only handle events for this session - if (session_id !== sessionId) return; - - setEditingFiles((prev) => { - const newMap = new Map(prev); - const existing = newMap.get(remote_path); - if (existing) { - newMap.set(remote_path, { - ...existing, - status: success ? "synced" : "error", - error: error || undefined, - }); - } - return newMap; - }); - - // Auto-clear synced status after 3 seconds - if (success) { - const t = setTimeout(() => { - pendingTimeouts.delete(t); - if (!isMounted) return; - setEditingFiles((prev) => { - const newMap = new Map(prev); - const existing = newMap.get(remote_path); - if (existing && existing.status === "synced") { - newMap.set(remote_path, { ...existing, status: "synced" }); - } - return newMap; - }); - }, 3000); - pendingTimeouts.add(t); + const updateFileStatus = (remotePath: string, status: "synced" | "error", error?: string) => { + setEditingFiles((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(remotePath); + if (existing) { + newMap.set(remotePath, { ...existing, status, error }); } + return newMap; }); + }; + + const handleFileUploaded = (event: { payload: FileUploadedEvent }) => { + if (!isMounted) return; + const { session_id, remote_path, success, error } = event.payload; + if (session_id !== sessionId) return; + + updateFileStatus(remote_path, success ? "synced" : "error", error); + if (success) { + const t = setTimeout(() => { + pendingTimeouts.delete(t); + if (!isMounted) return; + updateFileStatus(remote_path, "synced"); + }, 3000); + pendingTimeouts.add(t); + } + }; + + (async () => { + const unlisten = await listen("sftp-file-uploaded", handleFileUploaded); if (isMounted) { unlistenFn = unlisten; } else { @@ -178,12 +181,12 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) console.error("Failed to load editing files:", err); } }; - void loadEditingFiles(); + loadEditingFiles(); }, [sessionId]); const handleNavigate = (entry: FileEntry) => { if (entry.is_dir) { - void loadDirectory(entry.path); + loadDirectory(entry.path); setSelectedEntry(null); } }; @@ -193,15 +196,15 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) const parts = currentPath.split("/").filter(Boolean); parts.pop(); const newPath = "/" + parts.join("/"); - void loadDirectory(newPath || "/"); + loadDirectory(newPath || "/"); }; const handleGoHome = () => { - void loadDirectory("/"); + loadDirectory("/"); }; const handleRefresh = () => { - void loadDirectory(currentPath); + loadDirectory(currentPath); }; const handleDelete = async (entry: FileEntry) => { @@ -420,15 +423,17 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) {/* File list */}
- {loading && entries.length === 0 ? ( + {loading && entries.length === 0 && (
- ) : entries.length === 0 ? ( + )} + {!loading && entries.length === 0 && (
Empty directory
- ) : ( + )} + {entries.length > 0 && ( @@ -443,11 +448,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) key={entry.path} className={` border-b border-surface-0/10 cursor-pointer - ${contextMenu?.entry.path === entry.path - ? "bg-blue/20 ring-1 ring-blue/40 ring-inset" - : selectedEntry?.path === entry.path - ? "bg-blue/10" - : "hover:bg-surface-0/30"} + ${getRowHighlight(entry.path, contextMenu?.entry.path, selectedEntry?.path)} `} onClick={() => setSelectedEntry(entry)} onDoubleClick={() => handleNavigate(entry)} @@ -479,7 +480,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) {!isConnecting && ( ))} @@ -366,6 +372,7 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal {/* Overlay */}
- {isActive ? ( - - ) : tunnel.status.state === "Error" ? ( - - ) : null} + {tunnel.status.state === "Error" ? tunnel.status.error : tunnel.status.state} diff --git a/src/components/UI/PasswordInput.tsx b/src/components/UI/PasswordInput.tsx index 57691d2..898d62f 100644 --- a/src/components/UI/PasswordInput.tsx +++ b/src/components/UI/PasswordInput.tsx @@ -19,7 +19,7 @@ export function PasswordInput({ disabled, autoFocus, className, -}: PasswordInputProps) { +}: Readonly) { const [show, setShow] = useState(false); return ( diff --git a/src/components/Vault/PinInput.tsx b/src/components/Vault/PinInput.tsx index 5f89656..b83840b 100644 --- a/src/components/Vault/PinInput.tsx +++ b/src/components/Vault/PinInput.tsx @@ -19,8 +19,8 @@ export function PinInput({ error, disabled = false, autoFocus = true, -}: PinInputProps) { - const [digits, setDigits] = useState(Array(length).fill('')); +}: Readonly) { + const [digits, setDigits] = useState(new Array(length).fill('')); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); // Focus first input on mount @@ -33,14 +33,14 @@ export function PinInput({ // Reset digits when error changes (wrong PIN) useEffect(() => { if (error) { - setDigits(Array(length).fill('')); + setDigits(new Array(length).fill('')); inputRefs.current[0]?.focus(); } }, [error, length]); const handleChange = useCallback((index: number, value: string) => { // Only allow digits - const digit = value.replace(/\D/g, '').slice(-1); + const digit = value.replaceAll(/\D/g, '').slice(-1); const newDigits = [...digits]; newDigits[index] = digit; @@ -73,9 +73,9 @@ export function PinInput({ const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); - const paste = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length); + const paste = e.clipboardData.getData('text').replaceAll(/\D/g, '').slice(0, length); if (paste.length > 0) { - const newDigits = Array(length).fill(''); + const newDigits = new Array(length).fill(''); for (let i = 0; i < paste.length; i++) { newDigits[i] = paste[i]; } @@ -96,16 +96,16 @@ export function PinInput({ return (
- {digits.map((digit, index) => ( + {Array.from({ length }, (_, i) => i).map(pos => ( { inputRefs.current[index] = el; }} + key={pos} + ref={(el) => { inputRefs.current[pos] = el; }} type="password" inputMode="numeric" maxLength={1} - value={digit} - onChange={(e) => handleChange(index, e.target.value)} - onKeyDown={(e) => handleKeyDown(index, e)} + value={digits[pos]} + onChange={(e) => handleChange(pos, e.target.value)} + onKeyDown={(e) => handleKeyDown(pos, e)} onPaste={handlePaste} disabled={disabled} className={` @@ -117,7 +117,7 @@ export function PinInput({ transition-colors ${error ? 'border-error/50 animate-shake' : 'border-surface-0/50'} `} - aria-label={`PIN digit ${index + 1}`} + aria-label={`PIN digit ${pos + 1}`} /> ))}
diff --git a/src/components/Vault/VaultSetupModal.tsx b/src/components/Vault/VaultSetupModal.tsx index aeb2c80..3568f28 100644 --- a/src/components/Vault/VaultSetupModal.tsx +++ b/src/components/Vault/VaultSetupModal.tsx @@ -17,7 +17,25 @@ interface VaultSetupModalProps { type SetupStep = 'intro' | 'password' | 'pin' | 'settings'; -export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = true }: VaultSetupModalProps) { +const SETUP_STEPS: SetupStep[] = ['password', 'pin', 'settings']; + +function getStepClassName(currentStep: string, stepName: string, index: number): string { + if (currentStep === stepName) return 'bg-accent text-crust'; + if (SETUP_STEPS.indexOf(currentStep as SetupStep) > index) return 'bg-accent/30 text-accent'; + return 'bg-surface-0/30 text-text-muted'; +} + +function getPinStepInfo( + enablePin: boolean, + pinStep: 'enter' | 'confirm', + t: (key: string) => string, +): string { + if (!enablePin) return t('vault.setup.pinInfoDisabled'); + if (pinStep === 'enter') return t('vault.setup.pinInfoEnter'); + return t('vault.setup.pinInfoConfirm'); +} + +export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = true }: Readonly) { const { t } = useTranslation(); const [step, setStep] = useState('intro'); const [masterPassword, setMasterPassword] = useState(''); @@ -120,10 +138,7 @@ export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = tr className={` w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors - ${step === s ? 'bg-accent text-crust' : - ['password', 'pin', 'settings'].indexOf(step) > i - ? 'bg-accent/30 text-accent' - : 'bg-surface-0/30 text-text-muted'} + ${getStepClassName(step, s, i)} `} > {i + 1} @@ -253,30 +268,11 @@ export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = tr

- {enablePin - ? pinStep === 'enter' - ? t('vault.setup.pinInfoEnter') - : t('vault.setup.pinInfoConfirm') - : t('vault.setup.pinInfoDisabled')} + {getPinStepInfo(enablePin, pinStep, t)}

- {!enablePin ? ( -
- - -
- ) : ( + {enablePin ? (
+ ) : ( +
+ + +
)}
)} diff --git a/src/components/Vault/VaultUnlockModal.tsx b/src/components/Vault/VaultUnlockModal.tsx index e717226..59a8b4c 100644 --- a/src/components/Vault/VaultUnlockModal.tsx +++ b/src/components/Vault/VaultUnlockModal.tsx @@ -28,7 +28,7 @@ export function VaultUnlockModal({ onUnlockWithPassword, onUnlockWithPin, onUnlockWithSecurityKey, -}: VaultUnlockModalProps) { +}: Readonly) { const { t } = useTranslation(); const hasPin = unlockMethods.includes('pin'); const hasSecurityKey = unlockMethods.includes('security_key'); diff --git a/src/hooks/useAppSettings.ts b/src/hooks/useAppSettings.ts index 9f8f26b..b8c6c2c 100644 --- a/src/hooks/useAppSettings.ts +++ b/src/hooks/useAppSettings.ts @@ -25,7 +25,7 @@ export function useAppSettings() { } } }; - void loadSettings(); + loadSettings(); return () => { active = false; }; diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index d75821e..d79768c 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -28,7 +28,7 @@ export function useSessions() { // Load on mount useEffect(() => { isMountedRef.current = true; - void loadSavedSessions(); + loadSavedSessions(); return () => { isMountedRef.current = false; }; diff --git a/src/hooks/useSshKeys.ts b/src/hooks/useSshKeys.ts index 69ea77e..e58fbd3 100644 --- a/src/hooks/useSshKeys.ts +++ b/src/hooks/useSshKeys.ts @@ -27,7 +27,7 @@ export function useSshKeys() { useEffect(() => { isMountedRef.current = true; - void refresh(); + refresh(); return () => { isMountedRef.current = false; }; diff --git a/src/hooks/useVault.ts b/src/hooks/useVault.ts index 74cd9cb..4e3634e 100644 --- a/src/hooks/useVault.ts +++ b/src/hooks/useVault.ts @@ -36,7 +36,7 @@ export function useVault() { let active = true; // Check for auto-lock every 10 seconds - autoLockIntervalRef.current = window.setInterval(async () => { + autoLockIntervalRef.current = globalThis.setInterval(async () => { if (!active) return; try { const locked = await invoke('check_vault_auto_lock'); diff --git a/src/hooks/useVaultFlow.ts b/src/hooks/useVaultFlow.ts index bea9850..b654317 100644 --- a/src/hooks/useVaultFlow.ts +++ b/src/hooks/useVaultFlow.ts @@ -13,13 +13,11 @@ export function useVaultFlow() { useEffect(() => { if (vault.isLoading || settingsLoading) return; - if (!vault.status?.exists) { - // No vault exists - show setup modal (unless skipped in settings) - if (!settings.security?.vaultSetupSkipped) { - setShowVaultSetup(true); - } + if (vault.status?.exists && vault.status?.isUnlocked) { + // Vault is unlocked - hide all modals + setShowVaultSetup(false); setShowVaultUnlock(false); - } else if (!vault.status?.isUnlocked) { + } else if (vault.status?.exists) { // Vault exists but is locked // Only show unlock modal on initial load, not after auto-lock if (!initialVaultCheckDone) { @@ -27,8 +25,10 @@ export function useVaultFlow() { setShowVaultUnlock(true); } } else { - // Vault is unlocked - hide all modals - setShowVaultSetup(false); + // No vault exists - show setup modal (unless skipped in settings) + if (!settings.security?.vaultSetupSkipped) { + setShowVaultSetup(true); + } setShowVaultUnlock(false); } @@ -40,7 +40,7 @@ export function useVaultFlow() { const handleVaultSetupSkip = useCallback(() => { // Persist the skip choice in settings - void updateSettings({ + updateSettings({ ...settings, security: { ...settings.security, vaultSetupSkipped: true }, }); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 10ff8f0..bcf2b57 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -9,41 +9,35 @@ const loaders: Record Promise<{ default: Record } fr: () => import('./locales/fr'), }; -// Load the initial locale synchronously via eager import for instant rendering -async function bootstrap() { - const initialTranslations = await loaders[savedLanguage](); +// Load the initial locale via eager import for instant rendering +const initialTranslations = await loaders[savedLanguage](); - await i18n - .use(initReactI18next) - .init({ - resources: { - [savedLanguage]: { translation: initialTranslations.default }, - }, - lng: savedLanguage, - fallbackLng: 'en', - interpolation: { - escapeValue: false, - }, - }); - - // Persist language changes and lazy-load missing bundles - i18n.on('languageChanged', async (lng) => { - localStorage.setItem('language', lng); - if (!i18n.hasResourceBundle(lng, 'translation')) { - const mod = await loaders[lng](); - i18n.addResourceBundle(lng, 'translation', mod.default); - } +await i18n + .use(initReactI18next) + .init({ + resources: { + [savedLanguage]: { translation: initialTranslations.default }, + }, + lng: savedLanguage, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, }); -} -// Bootstrap promise — consumers can await this to ensure translations are ready -export const i18nReady = bootstrap().then(() => { - // Eagerly load fallback locale in background if it's not the active one - if (savedLanguage !== 'en' && !i18n.hasResourceBundle('en', 'translation')) { - loaders['en']().then((mod) => { - i18n.addResourceBundle('en', 'translation', mod.default); - }); +// Persist language changes and lazy-load missing bundles +i18n.on('languageChanged', async (lng) => { + localStorage.setItem('language', lng); + if (!i18n.hasResourceBundle(lng, 'translation')) { + const mod = await loaders[lng](); + i18n.addResourceBundle(lng, 'translation', mod.default); } }); +// Eagerly load fallback locale in background if it's not the active one +if (savedLanguage !== 'en' && !i18n.hasResourceBundle('en', 'translation')) { + const fallbackMod = await loaders['en'](); + i18n.addResourceBundle('en', 'translation', fallbackMod.default); +} + export default i18n; diff --git a/src/main.tsx b/src/main.tsx index cb135c5..9d9f0e3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,14 +1,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { i18nReady } from "./i18n"; +import "./i18n"; import App from "./App"; import "./styles/global.css"; -// Wait for i18n locale to load (code-split) before rendering -i18nReady.then(() => { - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , - ); -}); +// i18n module uses top-level await, so translations are ready by this point +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/src/plugins/PluginHost.tsx b/src/plugins/PluginHost.tsx index 856c3b3..1f959cf 100644 --- a/src/plugins/PluginHost.tsx +++ b/src/plugins/PluginHost.tsx @@ -40,7 +40,7 @@ export function PluginHost({ onStatusBarItemsChanged, onHeaderActionsChanged, onConnectSsh, -}: PluginHostProps) { +}: Readonly) { const [panels, setPanels] = useState>(new Map()); const [visiblePanels, setVisiblePanels] = useState>(new Set()); @@ -60,7 +60,7 @@ export function PluginHost({ useEffect(() => { return pluginManager.subscribe((event) => { switch (event.type) { - case 'panel:register': + case 'panel:register': { setPanels(new Map(pluginManager.registeredPanels)); // Auto-show floating widgets const entry = pluginManager.registeredPanels.get(event.panelId); @@ -68,6 +68,7 @@ export function PluginHost({ setVisiblePanels((prev) => new Set([...prev, event.panelId])); } break; + } case 'panel:show': setVisiblePanels((prev) => new Set([...prev, event.panelId])); break; @@ -180,7 +181,7 @@ export function usePlugins() { }, []); useEffect(() => { - void refresh(); + refresh(); }, [refresh]); const enablePlugin = useCallback(async (id: string) => { diff --git a/src/plugins/PluginManager.ts b/src/plugins/PluginManager.ts index af99ecd..6851612 100644 --- a/src/plugins/PluginManager.ts +++ b/src/plugins/PluginManager.ts @@ -2,9 +2,14 @@ * Plugin Manager - Frontend orchestration of plugins * * Handles loading, lifecycle, and communication with plugins. - * Supports both legacy (module.exports.default) and new (window.SimplyTermPlugins) formats. + * Supports both legacy (module.exports.default) and new (globalThis.SimplyTermPlugins) formats. */ +declare global { + // eslint-disable-next-line no-var + var SimplyTermPlugins: Record | undefined; +} + import { invoke } from '@tauri-apps/api/core'; import { createPluginAPI } from './PluginAPI'; import type { @@ -51,8 +56,8 @@ declare global { } export class PluginManager { - private plugins: Map = new Map(); - private eventHandlers: Set = new Set(); + private readonly plugins: Map = new Map(); + private readonly eventHandlers: Set = new Set(); // External callbacks (set by App.tsx) public onShowNotification: (message: string, type: NotificationType) => void = () => {}; @@ -225,7 +230,7 @@ export class PluginManager { * Execute plugin code * * Supports two formats: - * 1. New format (v1): window.SimplyTermPlugins[id] = { init, cleanup } + * 1. New format (v1): globalThis.SimplyTermPlugins[id] = { init, cleanup } * 2. Legacy format: module.exports.default = (api) => {} * * SECURITY NOTE: Plugins have full JavaScript execution within the renderer. @@ -242,10 +247,10 @@ export class PluginManager { ): Promise { try { // Initialize global plugins object - window.SimplyTermPlugins = window.SimplyTermPlugins || {}; + globalThis.SimplyTermPlugins = globalThis.SimplyTermPlugins || {}; // Execute the plugin code in global context - // This allows plugins to register themselves on window.SimplyTermPlugins + // This allows plugins to register themselves on globalThis.SimplyTermPlugins const wrappedCode = ` (function() { // Provide CommonJS-like environment for legacy plugins @@ -256,8 +261,8 @@ export class PluginManager { // If legacy format was used, convert to new format if (typeof module.exports.default === 'function') { - window.SimplyTermPlugins = window.SimplyTermPlugins || {}; - window.SimplyTermPlugins['${pluginId}'] = { + globalThis.SimplyTermPlugins = globalThis.SimplyTermPlugins || {}; + globalThis.SimplyTermPlugins['${pluginId}'] = { init: function(api) { return module.exports.default(api); } @@ -271,7 +276,7 @@ export class PluginManager { new Function(wrappedCode)(); // Check if plugin registered itself - const pluginModule = window.SimplyTermPlugins?.[pluginId]; + const pluginModule = globalThis.SimplyTermPlugins?.[pluginId]; if (pluginModule && typeof pluginModule.init === 'function') { // Pass the API object to the init function await pluginModule.init(api); @@ -285,6 +290,17 @@ export class PluginManager { } } + private removeEntriesByPlugin(map: Map, pluginId: string): boolean { + let changed = false; + for (const [itemId, entry] of map) { + if (entry.pluginId === pluginId) { + map.delete(itemId); + changed = true; + } + } + return changed; + } + /** * Unload a plugin */ @@ -293,7 +309,7 @@ export class PluginManager { if (!plugin) return; // Call cleanup if available - const pluginModule = window.SimplyTermPlugins?.[id]; + const pluginModule = globalThis.SimplyTermPlugins?.[id]; if (pluginModule?.cleanup) { try { pluginModule.cleanup(); @@ -366,26 +382,12 @@ export class PluginManager { }); // Remove registered status bar items for this plugin - let statusBarChanged = false; - for (const [itemId, entry] of this.registeredStatusBarItems) { - if (entry.pluginId === id) { - this.registeredStatusBarItems.delete(itemId); - statusBarChanged = true; - } - } - if (statusBarChanged) { + if (this.removeEntriesByPlugin(this.registeredStatusBarItems, id)) { this.notifyStatusBarChanged(); } // Remove registered header actions for this plugin - let headerActionsChanged = false; - for (const [itemId, entry] of this.registeredHeaderActions) { - if (entry.pluginId === id) { - this.registeredHeaderActions.delete(itemId); - headerActionsChanged = true; - } - } - if (headerActionsChanged) { + if (this.removeEntriesByPlugin(this.registeredHeaderActions, id)) { this.notifyHeaderActionsChanged(); } @@ -393,8 +395,8 @@ export class PluginManager { this.plugins.delete(id); // Remove from global registry - if (window.SimplyTermPlugins) { - delete window.SimplyTermPlugins[id]; + if (globalThis.SimplyTermPlugins) { + delete globalThis.SimplyTermPlugins[id]; } this.emit({ type: 'plugin:unloaded', pluginId: id }); diff --git a/src/plugins/PluginPanel.tsx b/src/plugins/PluginPanel.tsx index ef6ffb9..59b8e2b 100644 --- a/src/plugins/PluginPanel.tsx +++ b/src/plugins/PluginPanel.tsx @@ -18,7 +18,7 @@ interface PluginPanelProps { onClose?: () => void; } -export function PluginPanel({ pluginId, panel, visible, onClose }: PluginPanelProps) { +export function PluginPanel({ pluginId, panel, visible, onClose }: Readonly) { const containerRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); const observerCleanupRef = useRef<(() => void) | null>(null); diff --git a/src/plugins/PluginSettingsPanel.tsx b/src/plugins/PluginSettingsPanel.tsx index a1e6665..315c6ec 100644 --- a/src/plugins/PluginSettingsPanel.tsx +++ b/src/plugins/PluginSettingsPanel.tsx @@ -16,7 +16,7 @@ interface PluginSettingsPanelProps { /** * Single plugin settings panel content renderer */ -export function PluginSettingsPanel({ pluginId, panel }: PluginSettingsPanelProps) { +export function PluginSettingsPanel({ pluginId, panel }: Readonly) { const containerRef = useRef(null); const cleanupRef = useRef<(() => void) | void>(undefined); diff --git a/src/plugins/PluginSidebarSection.tsx b/src/plugins/PluginSidebarSection.tsx index 2c6d4db..ff4c003 100644 --- a/src/plugins/PluginSidebarSection.tsx +++ b/src/plugins/PluginSidebarSection.tsx @@ -18,7 +18,7 @@ interface PluginSidebarSectionProps { /** * Single plugin sidebar section */ -function PluginSidebarSection({ pluginId, section }: PluginSidebarSectionProps) { +function PluginSidebarSection({ pluginId, section }: Readonly) { const containerRef = useRef(null); const cleanupRef = useRef<(() => void) | void>(undefined); const [isCollapsed, setIsCollapsed] = useState(section.config.defaultCollapsed ?? false); @@ -60,19 +60,22 @@ function PluginSidebarSection({ pluginId, section }: PluginSidebarSectionProps) return (
{/* Section header */} -
isCollapsible && setIsCollapsed(!isCollapsed)} - > - {isCollapsible && ( + {isCollapsible ? ( +
+ {config.title} + + ) : ( +
+ {config.title} +
+ )} {/* Section content */} {!isCollapsed && ( diff --git a/src/plugins/PluginWidget.tsx b/src/plugins/PluginWidget.tsx index cb102e9..1bf1538 100644 --- a/src/plugins/PluginWidget.tsx +++ b/src/plugins/PluginWidget.tsx @@ -19,7 +19,7 @@ interface PluginWidgetProps { visible: boolean; } -export function PluginWidget({ pluginId, panel, position, visible }: PluginWidgetProps) { +export function PluginWidget({ pluginId, panel, position, visible }: Readonly) { const containerRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); const observerCleanupRef = useRef<(() => void) | null>(null); @@ -83,7 +83,7 @@ export function PluginWidget({ pluginId, panel, position, visible }: PluginWidge const positionClass = position === 'floating-left' ? 'left-3' : 'right-3'; return ( -
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -100,6 +100,6 @@ export function PluginWidget({ pluginId, panel, position, visible }: PluginWidge className="plugin-widget-content" />
-
+ ); } diff --git a/src/plugins/extensionTypes.ts b/src/plugins/extensionTypes.ts index edca39c..c973c8b 100644 --- a/src/plugins/extensionTypes.ts +++ b/src/plugins/extensionTypes.ts @@ -43,7 +43,7 @@ export interface SidebarViewRegistration { /** * Configuration for a sidebar section registered by a plugin - * @deprecated Use SidebarViewConfig instead for tab-based UI + * Legacy API - prefer SidebarViewConfig for new plugins */ export interface SidebarSectionConfig { /** Unique section identifier */ @@ -62,7 +62,7 @@ export interface SidebarSectionConfig { /** * Registration object for a sidebar section - * @deprecated Use SidebarViewRegistration instead for tab-based UI + * Legacy API - prefer SidebarViewRegistration for new plugins */ export interface SidebarSectionRegistration { /** Section configuration */ diff --git a/src/plugins/sanitize.ts b/src/plugins/sanitize.ts index d9120c9..eef4008 100644 --- a/src/plugins/sanitize.ts +++ b/src/plugins/sanitize.ts @@ -131,7 +131,7 @@ const purifyConfig = { // Block dangerous URI schemes ALLOWED_URI_REGEXP: - /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i, // Forbid certain protocols FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form"], @@ -192,41 +192,36 @@ export function sanitizeElement(element: HTMLElement): void { * @param element - Element to observe * @returns Cleanup function to disconnect the observer */ +function hasDangerousNodes(addedNodes: NodeList): boolean { + for (const node of addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const el = node as HTMLElement; + if ( + el.querySelector("script, iframe, object, embed") || + el.innerHTML.includes("javascript:") || + el.innerHTML.includes("on") // Potential event handlers + ) { + return true; + } + } + return false; +} + +function isDangerousAttribute(attrName: string | null): boolean { + const name = attrName?.toLowerCase() || ""; + return name.startsWith("on") || name === "href" || name === "src"; +} + export function observeAndSanitize(element: HTMLElement): () => void { const observer = new MutationObserver((mutations) => { // Temporarily disconnect to avoid infinite loop observer.disconnect(); - // Check if any mutation added potentially dangerous content - let needsSanitization = false; - - for (const mutation of mutations) { - if (mutation.type === "childList") { - for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - // Check for dangerous patterns - if ( - el.querySelector("script, iframe, object, embed") || - el.innerHTML.includes("javascript:") || - el.innerHTML.includes("on") // Potential event handlers - ) { - needsSanitization = true; - break; - } - } - } - } else if (mutation.type === "attributes") { - const attrName = mutation.attributeName?.toLowerCase() || ""; - if ( - attrName.startsWith("on") || - attrName === "href" || - attrName === "src" - ) { - needsSanitization = true; - } - } - } + const needsSanitization = mutations.some((mutation) => { + if (mutation.type === "childList") return hasDangerousNodes(mutation.addedNodes); + if (mutation.type === "attributes") return isDangerousAttribute(mutation.attributeName); + return false; + }); if (needsSanitization) { sanitizeElement(element); diff --git a/src/themes/index.ts b/src/themes/index.ts index 4badbde..8f91875 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -47,7 +47,7 @@ export function registerTheme(theme: Theme): void { themes.set(theme.meta.id, theme); // Dispatch event for reactive updates - window.dispatchEvent(new CustomEvent("simplyterm:theme-registered", { detail: theme })); + globalThis.dispatchEvent(new CustomEvent("simplyterm:theme-registered", { detail: theme })); } /** @@ -61,7 +61,7 @@ export function unregisterTheme(id: string): void { themes.delete(id); // Dispatch event for reactive updates - window.dispatchEvent(new CustomEvent("simplyterm:theme-unregistered", { detail: { id } })); + globalThis.dispatchEvent(new CustomEvent("simplyterm:theme-unregistered", { detail: { id } })); } /** @@ -113,10 +113,10 @@ export function applyTheme(themeId: string): void { } // Body background: transparent when blur is enabled, otherwise solid color - if (root.dataset.blur !== "true") { - document.body.style.background = theme.colors.crust; - } else { + if (root.dataset.blur === "true") { document.body.style.background = "transparent"; + } else { + document.body.style.background = theme.colors.crust; } // Set data attribute for CSS selectors @@ -124,7 +124,7 @@ export function applyTheme(themeId: string): void { root.dataset.themeVariant = theme.meta.variant; // Dispatch event for components that need to react - window.dispatchEvent(new CustomEvent("simplyterm:theme-changed", { detail: theme })); + globalThis.dispatchEvent(new CustomEvent("simplyterm:theme-changed", { detail: theme })); } /** diff --git a/src/types/workspace.ts b/src/types/workspace.ts index 79dbef2..dade99b 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -1,5 +1,4 @@ -import { SshConnectionConfig } from "./index"; -import { TelnetConnectionConfig, SerialConnectionConfig } from "./index"; +import { SshConnectionConfig, TelnetConnectionConfig, SerialConnectionConfig } from "./index"; // ============================================================================ // Workspace Tree Types diff --git a/tsconfig.json b/tsconfig.json index 3934b8f..61eb7c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",