From c66798ce1046dcf161f3e3e1c8d6ebf1e733d0c3 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:01:23 +0100 Subject: [PATCH 01/19] fix(lint): remove void operators conflicting with SonarCloud S3735 Remove 24 void operator usages that were added for Qodana's JSIgnoredPromiseFromCall rule but conflict with SonarCloud's S3735 (CRITICAL). SonarCloud is now the primary static analysis tool. --- src/App.tsx | 2 +- src/components/HeaderBar.tsx | 2 +- .../Settings/DeveloperPluginsTab.tsx | 2 +- src/components/Settings/PluginsSettings.tsx | 8 ++++---- src/components/SftpBrowser.tsx | 18 +++++++++--------- src/components/Terminal.tsx | 2 +- src/components/TunnelManager.tsx | 2 +- src/components/TunnelSidebar.tsx | 2 +- src/hooks/useAppSettings.ts | 2 +- src/hooks/useSessions.ts | 2 +- src/hooks/useSshKeys.ts | 2 +- src/hooks/useVaultFlow.ts | 2 +- src/plugins/PluginHost.tsx | 2 +- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6345c1a..a6ff545 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -93,7 +93,7 @@ function App() { const handleToggleSidebarPin = useCallback(() => { const newPinned = !sidebarPinned; - void handleSettingsChange({ + handleSettingsChange({ ...appSettings, ui: { ...appSettings.ui, sidebarPinned: newPinned }, }); diff --git a/src/components/HeaderBar.tsx b/src/components/HeaderBar.tsx index 76e9315..5a3bf05 100644 --- a/src/components/HeaderBar.tsx +++ b/src/components/HeaderBar.tsx @@ -87,7 +87,7 @@ export default function HeaderBar({ onMouseDown={(e) => { if (e.buttons === 1 && !(e.target as HTMLElement).closest(".no-drag")) { if (e.detail === 2) { - void handleMaximize(); + handleMaximize(); } else { getCurrentWindow().startDragging(); } diff --git a/src/components/Settings/DeveloperPluginsTab.tsx b/src/components/Settings/DeveloperPluginsTab.tsx index 9a2e115..166564f 100644 --- a/src/components/Settings/DeveloperPluginsTab.tsx +++ b/src/components/Settings/DeveloperPluginsTab.tsx @@ -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 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/SftpBrowser.tsx b/src/components/SftpBrowser.tsx index 99f1231..8f2828c 100644 --- a/src/components/SftpBrowser.tsx +++ b/src/components/SftpBrowser.tsx @@ -94,7 +94,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) }, [sessionId]); useEffect(() => { - void loadDirectory(currentPath); + loadDirectory(currentPath); }, []); // Listen for file upload events @@ -178,12 +178,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 +193,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) => { @@ -479,7 +479,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) + + + + + ); + } + + return ( + + } + iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${statusClassName}`} + title={hasPin ? t("settings.security.pinConfigured") : t("settings.security.pinNotConfigured")} + description={hasPin ? t("settings.security.pinQuickUnlock") : t("settings.security.pinSetupPrompt")} + > +
+ {hasPin && ( + + )} + +
+
+
+ ); +} + +// --- Biometric Authentication --- + +function getBiometricTitle(biometricType: string | undefined, t: (key: string) => string): string { + if (biometricType === 'windows_hello') return t("settings.security.windowsHello"); + if (biometricType === 'touch_id') return t("settings.security.touchId"); + return t("settings.security.biometric"); +} + +function getBiometricDescription( + hasBiometric: boolean, + available: boolean | undefined, + t: (key: string) => string, +): string { + if (hasBiometric) return t("common.enabled"); + if (available) return t("common.notConfigured"); + return t("settings.security.biometricNotAvailable"); +} + +function BiometricSection({ vault }: AuthenticationSectionProps) { + const { t } = useTranslation(); + const hasBiometric = vault.status?.unlockMethods.includes('biometric') || false; + const statusClassName = hasBiometric ? 'bg-success/20 text-success' : 'bg-surface-0/50 text-text-muted'; + + return ( + + } + iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${statusClassName}`} + title={getBiometricTitle(vault.status?.biometricType, t)} + description={getBiometricDescription(hasBiometric, vault.status?.biometricAvailable, t)} + > +
+ {!vault.status?.biometricAvailable ? ( + + {t("common.notSupported")} + + ) : ( + + {t("common.comingSoon")} + + )} +
+
+
+ ); +} + +// --- FIDO2 Security Key --- + +interface SecurityKeyInfo { + productName: string; + manufacturer: string; + devicePath: string; + hasPin: boolean; +} + +function SecurityKeySetupContent({ + loading, + available, + keys, + onRefresh, + t, +}: { + loading: boolean; + available: boolean | null; + keys: SecurityKeyInfo[]; + onRefresh: () => void; + t: (key: string, opts?: Record) => string; +}) { + if (loading) { + return ( +
+
+ +
+

{t("settings.security.touchKey")}

+
+ ); + } + + if (available === false) { + return ( +
+

+ + {t("settings.security.noKeyDetected")} +

+

+ {t("settings.security.insertKeyPrompt")} +

+
+ ); + } + + if (keys.length === 0) { + if (isWindows) { + return ( +
+

+ {t("settings.security.windowsWebAuthnReady")} +

+

+ {t("settings.security.windowsWebAuthnHint")} +

+
+ ); + } + return ( +
+

+ {t("settings.security.noKeyDetected")} +

+

+ {t("settings.security.insertKeyPrompt")} +

+ +
+ ); + } + + return ( + <> +
+

+ + {t("settings.security.keyDetected", { count: keys.length })} +

+

+ {keys.map(k => k.productName).join(', ')} +

+
+ +
+

{t("settings.security.setupSteps")}

+
    +
  1. {t("settings.security.setupStep1")}
  2. +
  3. {t("settings.security.setupStep2")}
  4. +
+
+ + + + ); +} + +function SecurityKeySection({ vault }: AuthenticationSectionProps) { + const { t } = useTranslation(); + const [securityKeySuccess, triggerSecurityKeySuccess] = useAutoHideSuccess(); + const hasSecurityKey = vault.status?.unlockMethods.includes('security_key') || false; + + const [showSecurityKeySetup, setShowSecurityKeySetup] = useState(false); + const [securityKeyAvailable, setSecurityKeyAvailable] = useState(null); + const [detectedSecurityKeys, setDetectedSecurityKeys] = useState([]); + const [securityKeyError, setSecurityKeyError] = useState(null); + const [securityKeyLoading, setSecurityKeyLoading] = useState(false); + const handleOpenSecurityKeySetup = async () => { setShowSecurityKeySetup(true); setSecurityKeyError(null); @@ -127,253 +368,78 @@ export default function AuthenticationSection({ vault }: AuthenticationSectionPr } }; - return ( - <> - {/* PIN Management */} - - {!showPinSetup ? ( - } - iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${ - hasPin ? 'bg-success/20 text-success' : 'bg-surface-0/50 text-text-muted' - }`} - title={hasPin ? t("settings.security.pinConfigured") : t("settings.security.pinNotConfigured")} - description={hasPin ? t("settings.security.pinQuickUnlock") : t("settings.security.pinSetupPrompt")} - > -
- {hasPin && ( - - )} - -
-
- ) : ( -
-
- setNewPin(e.target.value.replace(/\D/g, ''))} - className="w-full px-4 py-3 bg-surface-0/30 border border-surface-0/50 rounded-xl text-text placeholder-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent" - /> - setConfirmPin(e.target.value.replace(/\D/g, ''))} - className="w-full px-4 py-3 bg-surface-0/30 border border-surface-0/50 rounded-xl text-text placeholder-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent" - /> -
- {pinError &&

{pinError}

} - {pinSuccess && ( -

- {t("settings.security.pinSetupSuccess")} -

- )} -
- - -
-
- )} -
+ const statusClassName = hasSecurityKey ? 'bg-success/20 text-success' : 'bg-surface-0/50 text-text-muted'; + const canSetup = !securityKeyLoading && (detectedSecurityKeys.length > 0 || isWindows); - {/* Biometric Authentication */} - + if (!showSecurityKeySetup) { + return ( + } - iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${ - vault.status?.unlockMethods.includes('biometric') - ? 'bg-success/20 text-success' - : 'bg-surface-0/50 text-text-muted' - }`} - title={vault.status?.biometricType === 'windows_hello' ? t("settings.security.windowsHello") : - vault.status?.biometricType === 'touch_id' ? t("settings.security.touchId") : - t("settings.security.biometric")} - description={vault.status?.unlockMethods.includes('biometric') - ? t("common.enabled") - : vault.status?.biometricAvailable - ? t("common.notConfigured") - : t("settings.security.biometricNotAvailable")} + icon={} + iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${statusClassName}`} + title={t("settings.security.fido2Key")} + description={hasSecurityKey ? t("settings.security.fido2Configured") : t("settings.security.fido2NotConfigured")} > -
- {!vault.status?.biometricAvailable ? ( - - {t("common.notSupported")} - - ) : ( - - {t("common.comingSoon")} - - )} -
-
-
- - {/* FIDO2 Security Key Authentication */} - - {!showSecurityKeySetup ? ( - } - iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${ - hasSecurityKey ? 'bg-success/20 text-success' : 'bg-surface-0/50 text-text-muted' - }`} - title={t("settings.security.fido2Key")} - description={hasSecurityKey ? t("settings.security.fido2Configured") : t("settings.security.fido2NotConfigured")} - > -
- {hasSecurityKey && ( - - )} +
+ {hasSecurityKey && ( -
- - ) : ( -
- {securityKeyLoading ? ( -
-
- -
-

{t("settings.security.touchKey")}

-
- ) : securityKeyAvailable === false ? ( -
-

- - {t("settings.security.noKeyDetected")} -

-

- {t("settings.security.insertKeyPrompt")} -

-
- ) : detectedSecurityKeys.length === 0 ? ( -
- {isWindows ? ( - <> -

- {t("settings.security.windowsWebAuthnReady")} -

-

- {t("settings.security.windowsWebAuthnHint")} -

- - ) : ( - <> -

- {t("settings.security.noKeyDetected")} -

-

- {t("settings.security.insertKeyPrompt")} -

- - - )} -
- ) : ( - <> - {/* Detected keys info */} -
-

- - {t("settings.security.keyDetected", { count: detectedSecurityKeys.length })} -

-

- {detectedSecurityKeys.map(k => k.productName).join(', ')} -

-
- -
-

{t("settings.security.setupSteps")}

-
    -
  1. {t("settings.security.setupStep1")}
  2. -
  3. {t("settings.security.setupStep2")}
  4. -
-
- - - )} + +
+ + + ); + } - {securityKeyError &&

{securityKeyError}

} - {securityKeySuccess && ( -

- {t("settings.security.keySetupSuccess")} -

- )} + return ( + +
+ -
- - -
-
+ {securityKeyError &&

{securityKeyError}

} + {securityKeySuccess && ( +

+ {t("settings.security.keySetupSuccess")} +

)} -
- + +
+ + +
+
+
); } From e3241cddbc6394e454caa1f596dcd1d1c2e32c39 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:10:39 +0100 Subject: [PATCH 03/19] refactor(app): reduce cognitive complexity and nesting in App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract handleSftpConnectFromPending, buildJumpHostParams, and saveSessionAfterConnect from handleSshConnect (CC 25→~6, S3776) - Replace sequential keyboard shortcut if-chains with data-driven shortcut table + matchesShortcut helper (CC 20→~2, S3776) - Convert .then() chain to async/await in update check to fix nesting depth >4 (S2004) --- src/App.tsx | 377 ++++++++++++++++++++++++++-------------------------- 1 file changed, 190 insertions(+), 187 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a6ff545..5a8d5e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,22 @@ 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 App() { const { t } = useTranslation(); @@ -320,44 +336,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 +422,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); } }; @@ -805,69 +833,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]); + }, [workspace, handleNewLocalTab, handleCloseTab, handleOpenConnectionModal]); // ============================================================================ // Command Palette @@ -966,21 +967,23 @@ function App() { // 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 From baf9977eb148062c60d9de0e2a40f084198588a8 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:12:57 +0100 Subject: [PATCH 04/19] refactor(sftp): reduce complexity and nesting in SftpBrowser - Extract handleFileUploaded and updateFileStatus from IIFE to fix function nesting depth >4 (S2004 x2) - Extract getRowHighlight and getEditIndicator helpers to replace nested ternary chains, reducing cognitive complexity (S3776) --- src/components/SftpBrowser.tsx | 108 +++++++++++++++------------------ 1 file changed, 49 insertions(+), 59 deletions(-) diff --git a/src/components/SftpBrowser.tsx b/src/components/SftpBrowser.tsx index 8f2828c..5dd2956 100644 --- a/src/components/SftpBrowser.tsx +++ b/src/components/SftpBrowser.tsx @@ -46,6 +46,18 @@ interface FileUploadedEvent { error?: string; } +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 = "/" }: SftpBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath); const [entries, setEntries] = useState([]); @@ -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 { @@ -443,11 +446,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)} @@ -498,24 +497,15 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) ) : ( <> {entry.name} - {isEditing(entry.path) && ( - - )} + {isEditing(entry.path) && (() => { + const indicator = getEditIndicator(getEditStatus(entry.path)); + return ( + + ); + })()} )} From 2fd9623bb2c561fd5c157156584c282df11c3e2d Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:14:48 +0100 Subject: [PATCH 05/19] refactor(plugins): reduce cognitive complexity in PluginManager and sanitize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract removeEntriesByPlugin helper in PluginManager to deduplicate status bar and header action cleanup (CC 16→~10, S3776) - Extract hasDangerousNodes and isDangerousAttribute in sanitize.ts, replace nested for/if chain with mutations.some() (CC 20→~8, S3776) --- src/plugins/PluginManager.ts | 29 +++++++++---------- src/plugins/sanitize.ts | 55 ++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/plugins/PluginManager.ts b/src/plugins/PluginManager.ts index af99ecd..80158c1 100644 --- a/src/plugins/PluginManager.ts +++ b/src/plugins/PluginManager.ts @@ -285,6 +285,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 */ @@ -366,26 +377,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(); } diff --git a/src/plugins/sanitize.ts b/src/plugins/sanitize.ts index d9120c9..e783e20 100644 --- a/src/plugins/sanitize.ts +++ b/src/plugins/sanitize.ts @@ -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); From 5fc1c63aeac19f38e5b4b8d1966cd34f2d94b057 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:28:06 +0100 Subject: [PATCH 06/19] fix(lint): resolve miscellaneous MAJOR SonarCloud issues - S2933: mark PluginManager fields as readonly - S6535: remove unnecessary escape in sanitize regex - S6836: wrap lexical declaration in case block (PluginHost) - S6582: use optional chaining in App.tsx (3 instances) - S7785: convert i18n/main.tsx to top-level await - S6479: replace array index keys with stable identifiers - S6853: fix label accessibility in SshKeyManager --- src/App.tsx | 21 +++++-- src/components/PluginModal.tsx | 4 +- src/components/Settings/PluginCards.tsx | 16 +++--- src/components/Settings/SettingsTab.tsx | 2 +- .../Settings/SettingsUIComponents.tsx | 17 +++--- src/components/SshKeyManager.tsx | 9 +-- src/components/Vault/PinInput.tsx | 14 ++--- src/i18n/index.ts | 57 +++++++++---------- src/main.tsx | 16 +++--- src/plugins/PluginHost.tsx | 3 +- src/plugins/PluginManager.ts | 4 +- src/plugins/sanitize.ts | 2 +- 12 files changed, 88 insertions(+), 77 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5a8d5e4..1fd8379 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,16 @@ function matchesShortcut(entry: ShortcutEntry, mod: boolean, e: KeyboardEvent, i 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(); @@ -811,7 +821,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); } @@ -903,7 +913,7 @@ function App() { const tab = activeTabRef.current; if (tab) { const newName = window.prompt("Enter new tab name:", tab.title); - if (newName && newName.trim()) { + if (newName?.trim()) { workspace.renameTab(tab.id, newName.trim()); } } @@ -1005,8 +1015,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) { @@ -1334,7 +1345,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/PluginModal.tsx b/src/components/PluginModal.tsx index c11f9d0..a036993 100644 --- a/src/components/PluginModal.tsx +++ b/src/components/PluginModal.tsx @@ -44,6 +44,7 @@ function PluginModal({ isOpen, config, onButtonClick, onClose }: PluginModalProp {/* Backdrop */}
@@ -51,6 +52,7 @@ function PluginModal({ isOpen, config, onButtonClick, onClose }: PluginModalProp
@@ -72,7 +74,7 @@ function PluginModal({ isOpen, config, onButtonClick, onClose }: PluginModalProp
{buttons.map((button, index) => (
diff --git a/src/components/PaneGroup/PaneGroupTabBar.tsx b/src/components/PaneGroup/PaneGroupTabBar.tsx index e9e69c5..139215f 100644 --- a/src/components/PaneGroup/PaneGroupTabBar.tsx +++ b/src/components/PaneGroup/PaneGroupTabBar.tsx @@ -98,7 +98,7 @@ export function PaneGroupTabBar({ }; return ( -
+
{/* New tab split button */}
{/* 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, +}: { + 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 &&

{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/DeveloperPluginsTab.tsx b/src/components/Settings/DeveloperPluginsTab.tsx index 166564f..5016eab 100644 --- a/src/components/Settings/DeveloperPluginsTab.tsx +++ b/src/components/Settings/DeveloperPluginsTab.tsx @@ -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, +}: { + 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/InstalledPluginsTab.tsx b/src/components/Settings/InstalledPluginsTab.tsx index b8ff0f1..b3fe2eb 100644 --- a/src/components/Settings/InstalledPluginsTab.tsx +++ b/src/components/Settings/InstalledPluginsTab.tsx @@ -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, +}: { + 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/VaultStatusSection.tsx b/src/components/Settings/VaultStatusSection.tsx index b987412..ecbd3d8 100644 --- a/src/components/Settings/VaultStatusSection.tsx +++ b/src/components/Settings/VaultStatusSection.tsx @@ -5,6 +5,13 @@ import { getAutoLockOptions } from "../../utils"; import { SettingGroup, SettingRow, Toggle } from "./SettingsUIComponents"; import type { useVault } from "../../hooks"; +function getMethodLabel(method: string, t: (key: string) => string): string { + if (method === 'master_password') return t("settings.security.methodPassword"); + if (method === 'pin') return t("settings.security.methodPin"); + if (method === 'security_key') return t("settings.security.methodSecurityKey"); + return method; +} + interface VaultStatusSectionProps { vault: ReturnType; } @@ -47,9 +54,7 @@ export default function VaultStatusSection({ vault }: VaultStatusSectionProps) { }`} title={vault.status?.isUnlocked ? t("settings.security.vaultUnlocked") : t("settings.security.vaultLocked")} description={`${t("settings.security.methods")}: ${vault.status?.unlockMethods.map(m => - m === 'master_password' ? t("settings.security.methodPassword") : - m === 'pin' ? t("settings.security.methodPin") : - m === 'security_key' ? t("settings.security.methodSecurityKey") : m + getMethodLabel(m, t) ).join(', ')}`} > {vault.status?.isUnlocked && ( diff --git a/src/components/SftpBrowser.tsx b/src/components/SftpBrowser.tsx index 5dd2956..46690dd 100644 --- a/src/components/SftpBrowser.tsx +++ b/src/components/SftpBrowser.tsx @@ -423,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 && ( @@ -531,6 +533,7 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) left: Math.min(contextMenu.x, window.innerWidth - 180), top: Math.min(contextMenu.y, window.innerHeight - 200), }} + role="menu" onClick={(e) => e.stopPropagation()} > {/* Edit externally (only for files) */} diff --git a/src/components/TunnelManager.tsx b/src/components/TunnelManager.tsx index b3ffd8e..371f8ea 100644 --- a/src/components/TunnelManager.tsx +++ b/src/components/TunnelManager.tsx @@ -37,6 +37,12 @@ interface TunnelManagerProps { type TunnelType = "local" | "remote" | "dynamic"; +function getTunnelTypeLabel(type: TunnelType): string { + if (type === "local") return "Local (-L)"; + if (type === "remote") return "Remote (-R)"; + return "Dynamic (-D)"; +} + function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = false }: TunnelManagerProps) { const [tunnels, setTunnels] = useState([]); const [error, setError] = useState(null); @@ -184,7 +190,7 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal }`} > {getTunnelIcon(type)} - {type === "local" ? "Local (-L)" : type === "remote" ? "Remote (-R)" : "Dynamic (-D)"} + {getTunnelTypeLabel(type)} ))} @@ -366,6 +372,7 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal {/* Overlay */}
diff --git a/src/components/TunnelSidebar.tsx b/src/components/TunnelSidebar.tsx index c50e0d3..9be1bb1 100644 --- a/src/components/TunnelSidebar.tsx +++ b/src/components/TunnelSidebar.tsx @@ -238,6 +238,7 @@ export default function TunnelSidebar({ className={`fixed inset-0 top-10 z-30 bg-black/40 transition-opacity duration-200 ${ isAnimating ? "opacity-100" : "opacity-0" }`} + role="presentation" onClick={onClose} /> @@ -325,7 +326,7 @@ export default function TunnelSidebar({ : "border-surface-0 text-text-muted hover:text-text hover:border-surface-0/80" }`} > - {type === "local" ? "Local" : type === "remote" ? "Remote" : "SOCKS5"} + {getTunnelTypeLabel(type)} ))}
@@ -465,6 +466,30 @@ export default function TunnelSidebar({ ); } +function getTunnelTypeLabel(type: string): string { + if (type === "local") return "Local"; + if (type === "remote") return "Remote"; + return "SOCKS5"; +} + +function TunnelStatusIcon({ + isActive, + tunnel, + getStatusColor, +}: { + isActive: boolean; + tunnel: Tunnel; + getStatusColor: (status: Tunnel["status"]) => string; +}) { + if (isActive) { + return ; + } + if (tunnel.status.state === "Error") { + return ; + } + return null; +} + interface TunnelItemProps { tunnel: Tunnel; onStop: () => void; @@ -502,11 +527,7 @@ function TunnelItem({ tunnel, onStop, onRemove, getTunnelIcon, getStatusColor }: )}
- {isActive ? ( - - ) : tunnel.status.state === "Error" ? ( - - ) : null} + {tunnel.status.state === "Error" ? tunnel.status.error : tunnel.status.state} diff --git a/src/components/Vault/VaultSetupModal.tsx b/src/components/Vault/VaultSetupModal.tsx index aeb2c80..1d80233 100644 --- a/src/components/Vault/VaultSetupModal.tsx +++ b/src/components/Vault/VaultSetupModal.tsx @@ -17,6 +17,24 @@ interface VaultSetupModalProps { type SetupStep = 'intro' | 'password' | 'pin' | 'settings'; +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 }: VaultSetupModalProps) { const { t } = useTranslation(); const [step, setStep] = useState('intro'); @@ -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,11 +268,7 @@ 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)}

From f4c2224dbf806c3b02124d5a2d6cab6d00fcc1e2 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:28:33 +0100 Subject: [PATCH 08/19] fix(a11y): add ARIA roles to interactive elements (S6848) Add appropriate ARIA roles to non-native interactive elements: - role="presentation" on overlay/backdrop click-to-close divs - role="dialog" on CommandPalette dialog container - role="menu" on context menus (Sidebar, SftpBrowser) - role="separator" + tabIndex on SplitHandle resize handles - role="presentation" on decorative clickable elements --- src/components/CommandPalette/CommandPalette.tsx | 2 ++ src/components/HeaderBar.tsx | 1 + src/components/Modal.tsx | 1 + src/components/PaneGroup/PaneGroup.tsx | 1 + src/components/PromptModal.tsx | 2 ++ src/components/Sidebar.tsx | 2 ++ src/components/SplitHandle.tsx | 2 ++ src/components/Terminal.tsx | 1 + src/plugins/PluginSidebarSection.tsx | 2 ++ src/plugins/PluginWidget.tsx | 1 + 10 files changed, 15 insertions(+) diff --git a/src/components/CommandPalette/CommandPalette.tsx b/src/components/CommandPalette/CommandPalette.tsx index 440e1b6..019cfdc 100644 --- a/src/components/CommandPalette/CommandPalette.tsx +++ b/src/components/CommandPalette/CommandPalette.tsx @@ -55,6 +55,7 @@ export function CommandPalette({ {/* Overlay */}
@@ -67,6 +68,7 @@ export function CommandPalette({ animate-scale-in overflow-hidden " + role="dialog" onKeyDown={onKeyDown} > {/* Search input */} diff --git a/src/components/HeaderBar.tsx b/src/components/HeaderBar.tsx index 5a3bf05..8c2f268 100644 --- a/src/components/HeaderBar.tsx +++ b/src/components/HeaderBar.tsx @@ -84,6 +84,7 @@ export default function HeaderBar({ return (
{ if (e.buttons === 1 && !(e.target as HTMLElement).closest(".no-drag")) { if (e.detail === 2) { diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 0cd0d88..7fc6c0b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -44,6 +44,7 @@ function Modal({ isOpen, onClose, title, children, width = "md" }: ModalProps) { {/* Overlay */}
diff --git a/src/components/PaneGroup/PaneGroup.tsx b/src/components/PaneGroup/PaneGroup.tsx index e31f261..76e8ee9 100644 --- a/src/components/PaneGroup/PaneGroup.tsx +++ b/src/components/PaneGroup/PaneGroup.tsx @@ -40,6 +40,7 @@ export const PaneGroupComponent = memo(function PaneGroupComponent({
{/* Tab bar */} diff --git a/src/components/PromptModal.tsx b/src/components/PromptModal.tsx index 77c4a87..b42dd70 100644 --- a/src/components/PromptModal.tsx +++ b/src/components/PromptModal.tsx @@ -45,6 +45,7 @@ function PromptModal({ isOpen, config, onConfirm, onCancel }: PromptModalProps) {/* Backdrop */}
@@ -52,6 +53,7 @@ function PromptModal({ isOpen, config, onConfirm, onCancel }: PromptModalProps)
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index edcbbe6..bfde24a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -256,6 +256,7 @@ const Sidebar = memo(function Sidebar({ className={`fixed inset-0 top-10 z-30 bg-black/40 transition-opacity duration-200 ${ isAnimating ? "opacity-100" : "opacity-0" }`} + role="presentation" onClick={onClose} /> @@ -608,6 +609,7 @@ const SavedSessionItem = memo(function SavedSessionItem({
e.stopPropagation()} > - )} +
+ + + {securityKeyError &&

{securityKeyError}

} + {securityKeySuccess && ( +

+ {t("settings.security.keySetupSuccess")} +

+ )} + +
+
- +
); } return ( -
- - - {securityKeyError &&

{securityKeyError}

} - {securityKeySuccess && ( -

- {t("settings.security.keySetupSuccess")} -

- )} - -
- + } + iconClassName={`w-10 h-10 rounded-lg flex items-center justify-center ${statusClassName}`} + title={t("settings.security.fido2Key")} + description={hasSecurityKey ? t("settings.security.fido2Configured") : t("settings.security.fido2NotConfigured")} + > +
+ {hasSecurityKey && ( + + )}
-
+ ); } diff --git a/src/components/Settings/TerminalSettings.tsx b/src/components/Settings/TerminalSettings.tsx index 897ffa5..edaa3c3 100644 --- a/src/components/Settings/TerminalSettings.tsx +++ b/src/components/Settings/TerminalSettings.tsx @@ -11,7 +11,7 @@ interface TerminalSettingsProps { ) => void; } -export default function TerminalSettings({ settings, onChange }: TerminalSettingsProps) { +export default function TerminalSettings({ settings, onChange }: Readonly) { const { t } = useTranslation(); return ( @@ -44,7 +44,7 @@ export default function TerminalSettings({ settings, onChange }: TerminalSetting min={10} max={20} value={settings.fontSize} - onChange={(e) => onChange("fontSize", parseInt(e.target.value))} + onChange={(e) => onChange("fontSize", Number.parseInt(e.target.value))} className="flex-1 accent-accent" /> @@ -97,7 +97,7 @@ export default function TerminalSettings({ settings, onChange }: TerminalSetting max={50000} step={1000} value={settings.scrollback} - onChange={(e) => onChange("scrollback", parseInt(e.target.value))} + onChange={(e) => onChange("scrollback", Number.parseInt(e.target.value))} className="flex-1 accent-accent" /> diff --git a/src/components/TunnelManager.tsx b/src/components/TunnelManager.tsx index 371f8ea..7dfca27 100644 --- a/src/components/TunnelManager.tsx +++ b/src/components/TunnelManager.tsx @@ -43,7 +43,7 @@ function getTunnelTypeLabel(type: TunnelType): string { return "Dynamic (-D)"; } -function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = false }: TunnelManagerProps) { +function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = false }: Readonly) { const [tunnels, setTunnels] = useState([]); const [error, setError] = useState(null); @@ -84,9 +84,9 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal await invoke("tunnel_create", { sessionId, tunnelType, - localPort: parseInt(localPort), + localPort: Number.parseInt(localPort), remoteHost: tunnelType === "dynamic" ? null : remoteHost || null, - remotePort: tunnelType === "dynamic" ? null : parseInt(remotePort) || null, + remotePort: tunnelType === "dynamic" ? null : Number.parseInt(remotePort) || null, }); // Reset form @@ -126,7 +126,7 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal 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(1)) + " " + sizes[i]; + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; }; const getTunnelIcon = (type: TunnelType) => { @@ -374,6 +374,7 @@ function TunnelManager({ isOpen, onClose, sessionId, sessionName, embedded = fal className="absolute inset-0 bg-black/70 backdrop-blur-sm" role="presentation" onClick={onClose} + onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }} /> {/* Modal */} diff --git a/src/components/TunnelSidebar.tsx b/src/components/TunnelSidebar.tsx index 9be1bb1..2b3edbb 100644 --- a/src/components/TunnelSidebar.tsx +++ b/src/components/TunnelSidebar.tsx @@ -42,7 +42,7 @@ export default function TunnelSidebar({ onClose, savedSessions, onTunnelCountChange -}: TunnelSidebarProps) { +}: Readonly) { const { t } = useTranslation(); const [isAnimating, setIsAnimating] = useState(false); const [shouldRender, setShouldRender] = useState(false); @@ -172,9 +172,9 @@ export default function TunnelSidebar({ await invoke("tunnel_create", { sessionId: sshSessionId, tunnelType, - localPort: parseInt(localPort), + localPort: Number.parseInt(localPort), remoteHost: tunnelType === "dynamic" ? undefined : remoteHost, - remotePort: tunnelType === "dynamic" ? undefined : parseInt(remotePort), + remotePort: tunnelType === "dynamic" ? undefined : Number.parseInt(remotePort), }); // Reset form @@ -240,6 +240,7 @@ export default function TunnelSidebar({ }`} role="presentation" onClick={onClose} + onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }} /> {/* Sidebar panel - flottant à gauche */} @@ -284,15 +285,7 @@ export default function TunnelSidebar({
{/* New Tunnel Button/Form */}
- {!showNewForm ? ( - - ) : ( + {showNewForm ? ( {/* Session selector */}
@@ -404,6 +397,14 @@ export default function TunnelSidebar({
+ ) : ( + )}
@@ -476,11 +477,11 @@ function TunnelStatusIcon({ isActive, tunnel, getStatusColor, -}: { +}: Readonly<{ isActive: boolean; tunnel: Tunnel; getStatusColor: (status: Tunnel["status"]) => string; -}) { +}>) { if (isActive) { return ; } diff --git a/src/components/Vault/PinInput.tsx b/src/components/Vault/PinInput.tsx index 9df13ec..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]; } 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", From dde373345e2c69eb5684283e91c6e13e62ac3e85 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:54:18 +0100 Subject: [PATCH 10/19] fix(lint): prefer globalThis over window (S7764) Replace window.* with globalThis.* for platform-agnostic global access. Add global type declaration for SimplyTermPlugins in PluginManager. --- src/App.tsx | 16 +++++++--------- src/components/SftpBrowser.tsx | 7 ++++--- src/components/Sidebar.tsx | 15 ++++++++++----- src/components/Terminal.tsx | 7 ++++--- src/hooks/useVault.ts | 2 +- src/plugins/PluginManager.ts | 25 +++++++++++++++---------- src/themes/index.ts | 12 ++++++------ 7 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1fd8379..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,8 +29,7 @@ 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"; @@ -876,8 +874,8 @@ function App() { } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); }, [workspace, handleNewLocalTab, handleCloseTab, handleOpenConnectionModal]); // ============================================================================ @@ -912,7 +910,7 @@ function App() { renameTab: () => { const tab = activeTabRef.current; if (tab) { - const newName = window.prompt("Enter new tab name:", tab.title); + const newName = globalThis.prompt("Enter new tab name:", tab.title); if (newName?.trim()) { workspace.renameTab(tab.id, newName.trim()); } @@ -971,8 +969,8 @@ 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) diff --git a/src/components/SftpBrowser.tsx b/src/components/SftpBrowser.tsx index 46690dd..6750396 100644 --- a/src/components/SftpBrowser.tsx +++ b/src/components/SftpBrowser.tsx @@ -58,7 +58,7 @@ function getEditIndicator(status: string | undefined): { className: string; titl return { className: "bg-teal", title: "Watching for changes" }; } -export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps) { +export function SftpBrowser({ sessionId, initialPath = "/" }: Readonly) { const [currentPath, setCurrentPath] = useState(initialPath); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -530,11 +530,12 @@ export function SftpBrowser({ sessionId, initialPath = "/" }: SftpBrowserProps)
e.stopPropagation()} + onKeyDown={(e) => { if (e.key === 'Escape') setContextMenu(null); }} > {/* Edit externally (only for files) */} {!contextMenu.entry.is_dir && ( diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bfde24a..a659c6e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -258,6 +258,7 @@ const Sidebar = memo(function Sidebar({ }`} role="presentation" onClick={onClose} + onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }} /> {/* Sidebar panel - flottant */} @@ -507,11 +508,11 @@ const SavedSessionItem = memo(function SavedSessionItem({ // Listen for plugin-triggered re-renders (e.g., after tag assignment changes) const handleDecoratorChanged = () => renderDecorators(); - window.addEventListener('plugin-decorators-changed', handleDecoratorChanged); + globalThis.addEventListener('plugin-decorators-changed', handleDecoratorChanged); return () => { unsubscribe(); - window.removeEventListener('plugin-decorators-changed', handleDecoratorChanged); + globalThis.removeEventListener('plugin-decorators-changed', handleDecoratorChanged); cleanups.forEach(fn => fn()); }; }, [session.id]); @@ -520,7 +521,7 @@ const SavedSessionItem = memo(function SavedSessionItem({ e.preventDefault(); e.stopPropagation(); // Fermer tous les autres context menus - window.dispatchEvent(new CustomEvent("closeContextMenus")); + globalThis.dispatchEvent(new CustomEvent("closeContextMenus")); setContextMenu({ x: e.clientX, y: e.clientY }); }; @@ -555,11 +556,11 @@ const SavedSessionItem = memo(function SavedSessionItem({ const handleCloseAll = () => closeContextMenu(); document.addEventListener("click", handleClick); - window.addEventListener("closeContextMenus", handleCloseAll); + globalThis.addEventListener("closeContextMenus", handleCloseAll); return () => { document.removeEventListener("click", handleClick); - window.removeEventListener("closeContextMenus", handleCloseAll); + globalThis.removeEventListener("closeContextMenus", handleCloseAll); }; }, []); @@ -567,7 +568,10 @@ const SavedSessionItem = memo(function SavedSessionItem({ <>
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleConnect(); } }} onContextMenu={isConnecting ? undefined : handleContextMenu} + role="button" + tabIndex={isConnecting ? -1 : 0} className={`group/session w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left ${ isConnecting ? "bg-accent/10 cursor-wait" @@ -611,6 +615,7 @@ const SavedSessionItem = memo(function SavedSessionItem({ style={{ transform: `translate3d(${contextMenu.x}px, ${contextMenu.y}px, 0)`, left: 0, top: 0 }} role="menu" onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { if (e.key === 'Escape') closeContextMenu(); }} > - ) : ( + {showDeleteConfirm ? (

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

+ ) : ( + )} diff --git a/src/components/SplitHandle.tsx b/src/components/SplitHandle.tsx index 85827d5..692019e 100644 --- a/src/components/SplitHandle.tsx +++ b/src/components/SplitHandle.tsx @@ -6,7 +6,7 @@ interface SplitHandleProps { onDragStart?: () => void; } -export function SplitHandle({ direction, onDrag, onDragStart }: SplitHandleProps) { +export function SplitHandle({ direction, onDrag, onDragStart }: Readonly) { const handleRef = useRef(null); const isDragging = useRef(false); const startPos = useRef(0); diff --git a/src/components/SshKeyManager.tsx b/src/components/SshKeyManager.tsx index 1b2eb68..628f921 100644 --- a/src/components/SshKeyManager.tsx +++ b/src/components/SshKeyManager.tsx @@ -9,7 +9,7 @@ interface SshKeyManagerProps { isVaultUnlocked: boolean; } -export default function SshKeyManager({ isVaultUnlocked }: SshKeyManagerProps) { +export default function SshKeyManager({ isVaultUnlocked }: Readonly) { const { t } = useTranslation(); const { keys, createKey, updateKey, deleteKey } = useSshKeys(); const [showForm, setShowForm] = useState(false); @@ -184,6 +184,7 @@ export default function SshKeyManager({ isVaultUnlocked }: SshKeyManagerProps) { className="absolute inset-0 bg-black/70" role="presentation" onClick={() => !isDeleting && setConfirmDeleteKey(null)} + onKeyDown={(e) => { if (e.key === 'Escape' && !isDeleting) setConfirmDeleteKey(null); }} />
diff --git a/src/components/StatusBar/StatusBar.tsx b/src/components/StatusBar/StatusBar.tsx index 50c1ab1..146d594 100644 --- a/src/components/StatusBar/StatusBar.tsx +++ b/src/components/StatusBar/StatusBar.tsx @@ -75,7 +75,7 @@ const StatusBarSection = memo(function StatusBarSection({ items }: { items: Stat ); }); -function StatusBarItemRenderer({ item }: { item: StatusBarItem }) { +function StatusBarItemRenderer({ item }: Readonly<{ item: StatusBarItem }>) { const content = ( {item.content} ); diff --git a/src/components/TerminalPane.tsx b/src/components/TerminalPane.tsx index 4ccce23..1240837 100644 --- a/src/components/TerminalPane.tsx +++ b/src/components/TerminalPane.tsx @@ -15,7 +15,7 @@ function TerminalPane({ isActive = true, appTheme = "dark", terminalSettings, -}: TerminalPaneProps) { +}: Readonly) { return ( ) { const [show, setShow] = useState(false); return ( diff --git a/src/components/Vault/VaultSetupModal.tsx b/src/components/Vault/VaultSetupModal.tsx index 1d80233..3568f28 100644 --- a/src/components/Vault/VaultSetupModal.tsx +++ b/src/components/Vault/VaultSetupModal.tsx @@ -35,7 +35,7 @@ function getPinStepInfo( return t('vault.setup.pinInfoConfirm'); } -export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = true }: VaultSetupModalProps) { +export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = true }: Readonly) { const { t } = useTranslation(); const [step, setStep] = useState('intro'); const [masterPassword, setMasterPassword] = useState(''); @@ -272,22 +272,7 @@ export function VaultSetupModal({ isOpen, onClose, onSetup, onSkip, canSkip = tr

- {!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/plugins/PluginHost.tsx b/src/plugins/PluginHost.tsx index 37e6da8..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()); 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 b8832d7..27af142 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); @@ -67,6 +67,7 @@ function PluginSidebarSection({ pluginId, section }: PluginSidebarSectionProps) role={isCollapsible ? "button" : undefined} tabIndex={isCollapsible ? 0 : undefined} onClick={() => isCollapsible && setIsCollapsed(!isCollapsed)} + onKeyDown={isCollapsible ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsCollapsed(!isCollapsed); } } : undefined} > {isCollapsible && ( diff --git a/src/plugins/PluginWidget.tsx b/src/plugins/PluginWidget.tsx index 9d806f3..dce8749 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); From a8dff154fb45c3badd9eb73cbf8ca46772107761 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 15:55:06 +0100 Subject: [PATCH 13/19] fix(lint): flip negated conditions to positive form (S7735) Rewrite if/else and ternary patterns to test positive condition first. --- src/components/Settings/InlineVaultSetup.tsx | 18 ++++++++--------- .../Settings/PasswordChangeSection.tsx | 20 +++++++++---------- src/hooks/useVaultFlow.ts | 16 +++++++-------- 3 files changed, 27 insertions(+), 27 deletions(-) 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/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/hooks/useVaultFlow.ts b/src/hooks/useVaultFlow.ts index ec20192..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); } From 6fbd43e8055f21639a23801e1978032bee2ce9e8 Mon Sep 17 00:00:00 2001 From: Quentin Cattoen Date: Tue, 10 Feb 2026 16:07:38 +0100 Subject: [PATCH 14/19] fix(a11y): replace ARIA roles with semantic HTML, fix remaining PR issues (S6819, S6847, S6852, S6845, S6759, S4323, S2589, S7785) - Replace role="presentation" with aria-hidden="true" on backdrop overlays - Convert role="dialog" divs to native elements - Convert role="button" divs to
); } diff --git a/src/components/Connection/SerialConnectionForm.tsx b/src/components/Connection/SerialConnectionForm.tsx index 3c03fee..937d9e1 100644 --- a/src/components/Connection/SerialConnectionForm.tsx +++ b/src/components/Connection/SerialConnectionForm.tsx @@ -22,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; @@ -104,7 +104,7 @@ export const SerialFormContent = memo(function SerialFormContent(props: SerialFo props.setStopBits(Number.parseInt(e.target.value) as 1 | 2)} + onChange={(e) => props.setStopBits(Number.parseInt(e.target.value) as StopBits)} className="input-field" > {STOP_BITS.map((bits) => ( @@ -134,7 +134,7 @@ export const SerialFormContent = memo(function SerialFormContent(props: SerialFo - props.setFlowControl(e.target.value as "none" | "hardware" | "software") + props.setFlowControl(e.target.value as FlowControl) } className="input-field" > diff --git a/src/components/HostKeyModal.tsx b/src/components/HostKeyModal.tsx index f592b8f..43efec8 100644 --- a/src/components/HostKeyModal.tsx +++ b/src/components/HostKeyModal.tsx @@ -67,9 +67,8 @@ function HostKeyModal({ {/* Overlay */}