Refactors to enforce backend access via application hooks

Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability.

Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML.

Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
This commit is contained in:
bincxz
2025-12-13 01:38:44 +08:00
parent 5dae96eeb4
commit fb35f989b8
42 changed files with 1559 additions and 969 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmjs.org/

14
App.tsx
View File

@@ -3,6 +3,7 @@ import { activeTabStore, useIsVaultActive } from './application/state/activeTabS
import { useSessionState } from './application/state/useSessionState'; import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState'; import { useSettingsState } from './application/state/useSettingsState';
import { useVaultState } from './application/state/useVaultState'; import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { matchesKeyBinding } from './domain/models'; import { matchesKeyBinding } from './domain/models';
import ProtocolSelectDialog from './components/ProtocolSelectDialog'; import ProtocolSelectDialog from './components/ProtocolSelectDialog';
import { QuickSwitcher } from './components/QuickSwitcher'; import { QuickSwitcher } from './components/QuickSwitcher';
@@ -405,14 +406,15 @@ function App() {
setIsQuickSwitcherOpen(true); setIsQuickSwitcherOpen(true);
}, []); }, []);
const { openSettingsWindow } = useWindowControls();
const handleOpenSettings = useCallback(() => { const handleOpenSettings = useCallback(() => {
// Try to open in a separate window, fallback to modal dialog // Try to open in a separate window, fallback to modal dialog
if (window.netcatty?.openSettingsWindow) { void (async () => {
window.netcatty.openSettingsWindow(); const opened = await openSettingsWindow();
} else { if (!opened) setIsSettingsOpen(true);
setIsSettingsOpen(true); })();
} }, [openSettingsWindow]);
}, []);
const handleEndSessionDrag = useCallback(() => { const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null); setDraggingSessionId(null);

View File

@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useKeychainBackend = () => {
const generateKeyPair = useCallback(async (options: { type: "RSA" | "ECDSA" | "ED25519"; bits?: number; comment?: string }) => {
const bridge = netcattyBridge.get();
return bridge?.generateKeyPair?.(options);
}, []);
const execCommand = useCallback(async (options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
return bridge.execCommand(options);
}, []);
return { generateKeyPair, execCommand };
};

View File

@@ -0,0 +1,12 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useKnownHostsBackend = () => {
const readKnownHosts = useCallback(async () => {
const bridge = netcattyBridge.get();
return bridge?.readKnownHosts?.();
}, []);
return { readKnownHosts };
};

View File

@@ -1,10 +1,15 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { PortForwardingRule } from "../../domain/models"; import { Host, PortForwardingRule } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys"; import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PORT_FORWARDING,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter"; import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { import {
getActiveConnection, getActiveConnection,
getActiveRuleIds, getActiveRuleIds,
startPortForward,
stopPortForward,
} from "../../infrastructure/services/portForwardingService"; } from "../../infrastructure/services/portForwardingService";
export type ViewMode = "grid" | "list"; export type ViewMode = "grid" | "list";
@@ -16,11 +21,13 @@ export interface UsePortForwardingStateResult {
viewMode: ViewMode; viewMode: ViewMode;
sortMode: SortMode; sortMode: SortMode;
search: string; search: string;
preferFormMode: boolean;
setSelectedRuleId: (id: string | null) => void; setSelectedRuleId: (id: string | null) => void;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
setSortMode: (mode: SortMode) => void; setSortMode: (mode: SortMode) => void;
setSearch: (query: string) => void; setSearch: (query: string) => void;
setPreferFormMode: (prefer: boolean) => void;
addRule: ( addRule: (
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">, rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
@@ -35,6 +42,17 @@ export interface UsePortForwardingStateResult {
error?: string, error?: string,
) => void; ) => void;
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
onStatusChange?: (status: PortForwardingRule["status"]) => void,
) => Promise<{ success: boolean; error?: string }>;
filteredRules: PortForwardingRule[]; filteredRules: PortForwardingRule[];
selectedRule: PortForwardingRule | undefined; selectedRule: PortForwardingRule | undefined;
} }
@@ -45,6 +63,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
const [viewMode, setViewMode] = useState<ViewMode>("grid"); const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [sortMode, setSortMode] = useState<SortMode>("newest"); const [sortMode, setSortMode] = useState<SortMode>("newest");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [preferFormMode, setPreferFormModeState] = useState<boolean>(() => {
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
});
const setPreferFormMode = useCallback((prefer: boolean) => {
setPreferFormModeState(prefer);
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
}, []);
// Load rules from storage on mount // Load rules from storage on mount
useEffect(() => { useEffect(() => {
@@ -163,8 +189,39 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
[persistRules], [persistRules],
); );
const startTunnel = useCallback(
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,
) => void,
) => {
return startPortForward(rule, host, keys, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
});
},
[setRuleStatus],
);
const stopTunnel = useCallback(
async (
ruleId: string,
onStatusChange?: (status: PortForwardingRule["status"]) => void,
) => {
return stopPortForward(ruleId, (status) => {
setRuleStatus(ruleId, status);
onStatusChange?.(status);
});
},
[setRuleStatus],
);
// Filter and sort rules // Filter and sort rules
const filteredRules = useCallback(() => { const filteredRules = useMemo(() => {
let result = [...rules]; let result = [...rules];
// Filter by search // Filter by search
@@ -197,7 +254,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
} }
return result; return result;
}, [rules, search, sortMode])(); }, [rules, search, sortMode]);
const selectedRule = rules.find((r) => r.id === selectedRuleId); const selectedRule = rules.find((r) => r.id === selectedRuleId);
@@ -207,11 +264,13 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
viewMode, viewMode,
sortMode, sortMode,
search, search,
preferFormMode,
setSelectedRuleId, setSelectedRuleId,
setViewMode, setViewMode,
setSortMode, setSortMode,
setSearch, setSearch,
setPreferFormMode,
addRule, addRule,
updateRule, updateRule,
@@ -219,6 +278,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
duplicateRule, duplicateRule,
setRuleStatus, setRuleStatus,
startTunnel,
stopTunnel,
filteredRules, filteredRules,
selectedRule, selectedRule,

View File

@@ -36,10 +36,9 @@ export const useSessionState = () => {
username: 'local', username: 'local',
status: 'connecting', status: 'connecting',
}; };
setSessions(prev => [...prev, newSession]); setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId); setActiveTabId(sessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const connectToHost = useCallback((host: Host) => { const connectToHost = useCallback((host: Host) => {
const newSession: TerminalSession = { const newSession: TerminalSession = {
@@ -54,10 +53,9 @@ export const useSessionState = () => {
port: host.port, port: host.port,
moshEnabled: host.moshEnabled, moshEnabled: host.moshEnabled,
}; };
setSessions(prev => [...prev, newSession]); setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id); setActiveTabId(newSession.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => { const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s)); setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
@@ -140,11 +138,10 @@ export const useSessionState = () => {
} }
} }
} }
return prevSessions.filter(s => s.id !== sessionId); return prevSessions.filter(s => s.id !== sessionId);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [workspaces, setActiveTabId]);
}, [workspaces]);
const closeWorkspace = useCallback((workspaceId: string) => { const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => { setWorkspaces(prevWorkspaces => {
@@ -161,10 +158,9 @@ export const useSessionState = () => {
} }
} }
return remainingWorkspaces; return remainingWorkspaces;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const startWorkspaceRename = useCallback((workspaceId: string) => { const startWorkspaceRename = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => { setWorkspaces(prevWorkspaces => {
@@ -204,7 +200,7 @@ export const useSessionState = () => {
) => { ) => {
if (!hint || baseSessionId === joiningSessionId) return; if (!hint || baseSessionId === joiningSessionId) return;
setSessions(prevSessions => { setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId); const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId); const joining = prevSessions.find(s => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions; if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
@@ -219,9 +215,8 @@ export const useSessionState = () => {
} }
return s; return s;
}); });
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const addSessionToWorkspace = useCallback(( const addSessionToWorkspace = useCallback((
workspaceId: string, workspaceId: string,
@@ -230,7 +225,7 @@ export const useSessionState = () => {
) => { ) => {
if (!hint) return; if (!hint) return;
setSessions(prevSessions => { setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId); const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions; if (!session || session.workspaceId) return prevSessions;
@@ -246,9 +241,8 @@ export const useSessionState = () => {
setActiveTabId(workspaceId); setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s); return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => { const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
setWorkspaces(prev => prev.map(ws => { setWorkspaces(prev => prev.map(ws => {
@@ -263,7 +257,7 @@ export const useSessionState = () => {
sessionId: string, sessionId: string,
direction: SplitDirection direction: SplitDirection
) => { ) => {
setSessions(prevSessions => { setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId); const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions; if (!session) return prevSessions;
@@ -328,9 +322,8 @@ export const useSessionState = () => {
} }
return s; return s;
}).concat({ ...newSession, workspaceId: newWorkspace.id }); }).concat({ ...newSession, workspaceId: newWorkspace.id });
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
// Toggle workspace view mode between split and focus // Toggle workspace view mode between split and focus
const toggleWorkspaceViewMode = useCallback((workspaceId: string) => { const toggleWorkspaceViewMode = useCallback((workspaceId: string) => {
@@ -358,31 +351,23 @@ export const useSessionState = () => {
// Move focus between panes in a workspace // Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => { const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
console.log('[moveFocusInWorkspace] Called with:', { workspaceId, direction });
const workspace = workspaces.find(w => w.id === workspaceId); const workspace = workspaces.find(w => w.id === workspaceId);
if (!workspace) { if (!workspace) {
console.log('[moveFocusInWorkspace] Workspace not found');
return false; return false;
} }
// Get current focused session, or first session if none focused // Get current focused session, or first session if none focused
const sessionIds = collectSessionIds(workspace.root); const sessionIds = collectSessionIds(workspace.root);
console.log('[moveFocusInWorkspace] Session IDs:', sessionIds);
const currentFocused = workspace.focusedSessionId || sessionIds[0]; const currentFocused = workspace.focusedSessionId || sessionIds[0];
if (!currentFocused) { if (!currentFocused) {
console.log('[moveFocusInWorkspace] No current focused session');
return false; return false;
} }
console.log('[moveFocusInWorkspace] Current focused:', currentFocused);
// Find the next session in the given direction // Find the next session in the given direction
const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction); const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction);
console.log('[moveFocusInWorkspace] Next session:', nextSessionId);
if (!nextSessionId) { if (!nextSessionId) {
console.log('[moveFocusInWorkspace] No next session found');
return false; return false;
} }
@@ -392,7 +377,6 @@ export const useSessionState = () => {
return { ...ws, focusedSessionId: nextSessionId }; return { ...ws, focusedSessionId: nextSessionId };
})); }));
console.log('[moveFocusInWorkspace] Focus updated to:', nextSessionId);
return true; return true;
}, [workspaces]); }, [workspaces]);
@@ -428,11 +412,10 @@ export const useSessionState = () => {
startupCommand: snippet.command, startupCommand: snippet.command,
})); }));
setSessions(prev => [...prev, ...sessionsWithWorkspace]); setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]); setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id); setActiveTabId(workspace.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference }, [setActiveTabId]);
}, []);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]); const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);

View File

@@ -1,4 +1,4 @@
import { useCallback,useEffect,useMemo,useState } from 'react'; import { useCallback,useEffect,useMemo,useState } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding } from '../../domain/models'; import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding } from '../../domain/models';
import { import {
STORAGE_KEY_COLOR, STORAGE_KEY_COLOR,
@@ -15,6 +15,7 @@ STORAGE_KEY_CUSTOM_CSS,
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes'; import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts'; import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_COLOR = '221.2 83.2% 53.3%'; const DEFAULT_COLOR = '221.2 83.2% 53.3%';
const DEFAULT_THEME: 'light' | 'dark' = 'light'; const DEFAULT_THEME: 'light' | 'dark' = 'light';
@@ -40,7 +41,7 @@ const applyThemeTokens = (theme: 'light' | 'dark', primaryColor: string) => {
root.style.setProperty('--accent-foreground', accentForeground); root.style.setProperty('--accent-foreground', accentForeground);
// Sync with native window title bar (Electron) // Sync with native window title bar (Electron)
window.netcatty?.setTheme?.(theme); netcattyBridge.get()?.setTheme?.(theme);
}; };
export const useSettingsState = () => { export const useSettingsState = () => {
@@ -109,14 +110,14 @@ export const useSettingsState = () => {
} }
} }
// Sync terminal settings from other windows // Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) { if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try { try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings; const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings })); setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch { } catch {
// ignore parse errors // ignore parse errors
} }
} }
// Sync terminal theme from other windows // Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) { if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) { if (e.newValue !== terminalThemeId) {

View File

@@ -0,0 +1,206 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { RemoteFile } from "../../types";
export const useSftpBackend = () => {
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
const bridge = netcattyBridge.get();
if (!bridge?.openSftp) throw new Error("SFTP bridge unavailable");
return bridge.openSftp(options);
}, []);
const closeSftp = useCallback(async (sftpId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.closeSftp) throw new Error("SFTP bridge unavailable");
return bridge.closeSftp(sftpId);
}, []);
const listSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
return bridge.listSftp(sftpId, path);
}, []);
const readSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
return bridge.readSftp(sftpId, path);
}, []);
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
return bridge.readSftpBinary(sftpId, path);
}, []);
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
return bridge.writeSftp(sftpId, path, content);
}, []);
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
return bridge.writeSftpBinary(sftpId, path, content);
}, []);
const writeSftpBinaryWithProgress = useCallback(
async (
sftpId: string,
path: string,
content: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftpBinaryWithProgress) return undefined;
return bridge.writeSftpBinaryWithProgress(
sftpId,
path,
content,
transferId,
onProgress,
onComplete,
onError,
);
},
[],
);
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
return bridge.mkdirSftp(sftpId, path);
}, []);
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
return bridge.deleteSftp(sftpId, path);
}, []);
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
return bridge.renameSftp(sftpId, oldPath, newPath);
}, []);
const statSftp = useCallback(async (sftpId: string, path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
return bridge.statSftp(sftpId, path);
}, []);
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
return bridge.chmodSftp(sftpId, path, mode);
}, []);
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
const bridge = netcattyBridge.get();
if (!bridge?.listLocalDir) throw new Error("listLocalDir unavailable");
return bridge.listLocalDir(path);
}, []);
const readLocalFile = useCallback(async (path: string): Promise<ArrayBuffer> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) throw new Error("readLocalFile unavailable");
return bridge.readLocalFile(path);
}, []);
const writeLocalFile = useCallback(async (path: string, content: ArrayBuffer) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) throw new Error("writeLocalFile unavailable");
return bridge.writeLocalFile(path, content);
}, []);
const deleteLocalFile = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.deleteLocalFile) throw new Error("deleteLocalFile unavailable");
return bridge.deleteLocalFile(path);
}, []);
const renameLocalFile = useCallback(async (oldPath: string, newPath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.renameLocalFile) throw new Error("renameLocalFile unavailable");
return bridge.renameLocalFile(oldPath, newPath);
}, []);
const mkdirLocal = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.mkdirLocal) throw new Error("mkdirLocal unavailable");
return bridge.mkdirLocal(path);
}, []);
const statLocal = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.statLocal) throw new Error("statLocal unavailable");
return bridge.statLocal(path);
}, []);
const getHomeDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getHomeDir) return undefined;
return bridge.getHomeDir();
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) return undefined;
return bridge.startStreamTransfer(options, onProgress, onComplete, onError);
},
[],
);
const cancelTransfer = useCallback(async (transferId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.cancelTransfer) return undefined;
return bridge.cancelTransfer(transferId);
}, []);
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTransferProgress) return undefined;
return bridge.onTransferProgress(transferId, cb);
}, []);
return {
openSftp,
closeSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinary,
writeSftpBinaryWithProgress,
mkdirSftp,
deleteSftp,
renameSftp,
statSftp,
chmodSftp,
listLocalDir,
readLocalFile,
writeLocalFile,
deleteLocalFile,
renameLocalFile,
mkdirLocal,
statLocal,
getHomeDir,
startStreamTransfer,
cancelTransfer,
onTransferProgress,
};
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
FileConflict, FileConflict,
Host, Host,
@@ -9,6 +9,8 @@ import {
TransferStatus, TransferStatus,
TransferTask, TransferTask,
} from "../../domain/models"; } from "../../domain/models";
import { logger } from "../../lib/logger";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
// Helper functions // Helper functions
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
@@ -265,14 +267,14 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const intervalsRef = progressIntervalsRef.current; const intervalsRef = progressIntervalsRef.current;
return () => { return () => {
// Clear all SFTP sessions // Clear all SFTP sessions
sessionsRef.forEach(async (sftpId) => { sessionsRef.forEach(async (sftpId) => {
try { try {
await window.netcatty?.closeSftp(sftpId); await netcattyBridge.get()?.closeSftp(sftpId);
} catch { } catch {
// Ignore errors when closing SFTP sessions during cleanup // Ignore errors when closing SFTP sessions during cleanup
} }
}); });
// Clear all progress simulation intervals // Clear all progress simulation intervals
intervalsRef.forEach((interval) => { intervalsRef.forEach((interval) => {
clearInterval(interval); clearInterval(interval);
@@ -301,6 +303,47 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
[keys], [keys],
); );
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
// Fallback mock for development
return getMockLocalFiles(path);
}
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path);
if (!rawFiles) return [];
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
},
[],
);
// Connect to a host // Connect to a host
const connect = useCallback( const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => { async (side: "left" | "right", host: Host | "local") => {
@@ -323,25 +366,25 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const oldSftpId = sftpSessionsRef.current.get( const oldSftpId = sftpSessionsRef.current.get(
currentPane.connection.id, currentPane.connection.id,
); );
if (oldSftpId) { if (oldSftpId) {
try { try {
await window.netcatty?.closeSftp(oldSftpId); await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch { } catch {
// Ignore errors when closing stale SFTP sessions // Ignore errors when closing stale SFTP sessions
} }
sftpSessionsRef.current.delete(currentPane.connection.id); sftpSessionsRef.current.delete(currentPane.connection.id);
} }
} }
if (host === "local") { if (host === "local") {
// Local filesystem connection // Local filesystem connection
// Try to get home directory from backend, fallback to platform-specific default // Try to get home directory from backend, fallback to platform-specific default
let homeDir = await window.netcatty?.getHomeDir?.(); let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) { if (!homeDir) {
// Detect platform and use appropriate default // Detect platform and use appropriate default
const isWindows = navigator.platform.toLowerCase().includes("win"); const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao"; homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
} }
const connection: SftpConnection = { const connection: SftpConnection = {
id: connectionId, id: connectionId,
@@ -407,21 +450,21 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
files: prev.reconnecting ? prev.files : [], // Keep files if reconnecting files: prev.reconnecting ? prev.files : [], // Keep files if reconnecting
})); }));
try { try {
const credentials = getHostCredentials(host); const credentials = getHostCredentials(host);
const sftpId = await window.netcatty?.openSftp({ const sftpId = await netcattyBridge.get()?.openSftp({
sessionId: `sftp-${connectionId}`, sessionId: `sftp-${connectionId}`,
...credentials, ...credentials,
}); });
if (!sftpId) throw new Error("Failed to open SFTP session"); if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId); sftpSessionsRef.current.set(connectionId, sftpId);
// Try to get home directory, default to "/" // Try to get home directory, default to "/"
let startPath = "/"; let startPath = "/";
const statSftp = window.netcatty?.statSftp; const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) { if (statSftp) {
const candidates: string[] = []; const candidates: string[] = [];
if (credentials.username) { if (credentials.username) {
candidates.push(`/home/${credentials.username}`); candidates.push(`/home/${credentials.username}`);
@@ -438,26 +481,26 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
// Ignore missing/permission errors // Ignore missing/permission errors
} }
} }
} else { } else {
if (credentials.username) { if (credentials.username) {
try { try {
const homeFiles = await window.netcatty?.listSftp( const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId, sftpId,
`/home/${credentials.username}`, `/home/${credentials.username}`,
); );
if (homeFiles) startPath = `/home/${credentials.username}`; if (homeFiles) startPath = `/home/${credentials.username}`;
} catch { } catch {
// Fall through to /root check // Fall through to /root check
} }
} }
if (startPath === "/") { if (startPath === "/") {
try { try {
const rootFiles = await window.netcatty?.listSftp( const rootFiles = await netcattyBridge.get()?.listSftp(
sftpId, sftpId,
"/root", "/root",
); );
if (rootFiles) startPath = "/root"; if (rootFiles) startPath = "/root";
} catch { } catch {
// Fallback path not available, use default // Fallback path not available, use default
} }
} }
@@ -507,8 +550,15 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
} }
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps -- listLocalFiles is intentionally omitted to prevent infinite loops [
[getHostCredentials, leftPane, rightPane, clearCacheForConnection], getHostCredentials,
leftPane,
rightPane,
clearCacheForConnection,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
],
); );
// Auto-connect left pane to local filesystem on first mount // Auto-connect left pane to local filesystem on first mount
@@ -561,16 +611,16 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
lastConnectedHostRef.current[side] = null; lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) { if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id); const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) { if (sftpId) {
try { try {
await window.netcatty?.closeSftp(sftpId); await netcattyBridge.get()?.closeSftp(sftpId);
} catch { } catch {
// Ignore errors when closing SFTP session during disconnect // Ignore errors when closing SFTP session during disconnect
} }
sftpSessionsRef.current.delete(pane.connection.id); sftpSessionsRef.current.delete(pane.connection.id);
} }
} }
setPane({ setPane({
connection: null, connection: null,
@@ -586,7 +636,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
); );
// Mock local file data for development (when backend is not available) // Mock local file data for development (when backend is not available)
const getMockLocalFiles = (path: string): SftpFileEntry[] => { function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths) // Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/"; const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
@@ -1035,43 +1085,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
], ],
}; };
return mockData[normPath] || []; return mockData[normPath] || [];
}; }
// List local files
const listLocalFiles = async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await window.netcatty?.listLocalDir?.(path);
if (!rawFiles) {
// Fallback mock for development
return getMockLocalFiles(path);
}
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
};
// List remote files
const listRemoteFiles = async (
sftpId: string,
path: string,
): Promise<SftpFileEntry[]> => {
const rawFiles = await window.netcatty?.listSftp(sftpId, path);
if (!rawFiles) return [];
return rawFiles.map((f) => ({
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size: parseInt(f.size) || 0,
sizeFormatted: f.size,
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
}));
};
// Navigate to path // Navigate to path
const navigateTo = useCallback( const navigateTo = useCallback(
@@ -1181,8 +1195,14 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
})); }));
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps -- listLocalFiles is intentionally omitted to prevent infinite loops [
[leftPane, rightPane, makeCacheKey, clearCacheForConnection], leftPane,
rightPane,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
listRemoteFiles,
],
); );
// Refresh current directory // Refresh current directory
@@ -1305,18 +1325,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const fullPath = joinPath(pane.connection.currentPath, name); const fullPath = joinPath(pane.connection.currentPath, name);
try { try {
if (pane.connection.isLocal) { if (pane.connection.isLocal) {
await window.netcatty?.mkdirLocal?.(fullPath); await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else { } else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id); const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) { if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found")); handleSessionError(side, new Error("SFTP session not found"));
return; return;
} }
await window.netcatty?.mkdirSftp(sftpId, fullPath); await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath);
} }
await refresh(side); await refresh(side);
} catch (err) { } catch (err) {
if (isSessionError(err)) { if (isSessionError(err)) {
handleSessionError(side, err as Error); handleSessionError(side, err as Error);
@@ -1334,22 +1354,22 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const pane = side === "left" ? leftPane : rightPane; const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return; if (!pane.connection) return;
try { try {
for (const name of fileNames) { for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name); const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) { if (pane.connection.isLocal) {
await window.netcatty?.deleteLocalFile?.(fullPath); await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else { } else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id); const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) { if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found")); handleSessionError(side, new Error("SFTP session not found"));
return; return;
} }
await window.netcatty?.deleteSftp?.(sftpId, fullPath); await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath);
} }
} }
await refresh(side); await refresh(side);
} catch (err) { } catch (err) {
if (isSessionError(err)) { if (isSessionError(err)) {
handleSessionError(side, err as Error); handleSessionError(side, err as Error);
@@ -1370,18 +1390,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const oldPath = joinPath(pane.connection.currentPath, oldName); const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, newName); const newPath = joinPath(pane.connection.currentPath, newName);
try { try {
if (pane.connection.isLocal) { if (pane.connection.isLocal) {
await window.netcatty?.renameLocalFile?.(oldPath, newPath); await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else { } else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id); const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) { if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found")); handleSessionError(side, new Error("SFTP session not found"));
return; return;
} }
await window.netcatty?.renameSftp?.(sftpId, oldPath, newPath); await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath);
} }
await refresh(side); await refresh(side);
} catch (err) { } catch (err) {
if (isSessionError(err)) { if (isSessionError(err)) {
handleSessionError(side, err as Error); handleSessionError(side, err as Error);
@@ -1428,17 +1448,17 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let fileSize = 0; let fileSize = 0;
if (!file.isDirectory) { if (!file.isDirectory) {
try { try {
const fullPath = joinPath(sourcePath, file.name); const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) { if (sourcePane.connection!.isLocal) {
const stat = await window.netcatty?.statLocal?.(fullPath); const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size; if (stat) fileSize = stat.size;
} else if (sourceSftpId) { } else if (sourceSftpId) {
const stat = await window.netcatty?.statSftp?.( const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId, sourceSftpId,
fullPath, fullPath,
); );
if (stat) fileSize = stat.size; if (stat) fileSize = stat.size;
} }
} catch { } catch {
// If stat fails, we'll use estimate later // If stat fails, we'll use estimate later
} }
@@ -1488,35 +1508,35 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let actualFileSize = task.totalBytes; let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) { if (!task.isDirectory && actualFileSize === 0) {
try { try {
const sourceSftpId = sourcePane.connection?.isLocal const sourceSftpId = sourcePane.connection?.isLocal
? null ? null
: sftpSessionsRef.current.get(sourcePane.connection!.id); : sftpSessionsRef.current.get(sourcePane.connection!.id);
if (sourcePane.connection?.isLocal) { if (sourcePane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.sourcePath); const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size; if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) { } else if (sourceSftpId) {
const stat = await window.netcatty?.statSftp?.( const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId, sourceSftpId,
task.sourcePath, task.sourcePath,
); );
if (stat) actualFileSize = stat.size; if (stat) actualFileSize = stat.size;
} }
} catch { } catch {
// Ignore stat errors, use estimate // Ignore stat errors, use estimate
} }
} }
// Estimate file size for progress simulation (use a reasonable default if unknown) // Estimate file size for progress simulation (use a reasonable default if unknown)
const estimatedSize = const estimatedSize =
actualFileSize > 0 actualFileSize > 0
? actualFileSize ? actualFileSize
: task.isDirectory : task.isDirectory
? 1024 * 1024 // 1MB estimate for directories ? 1024 * 1024 // 1MB estimate for directories
: 256 * 1024; // 256KB default for files : 256 * 1024; // 256KB default for files
// Check if streaming transfer is available (will provide real progress) // Check if streaming transfer is available (will provide real progress)
const hasStreamingTransfer = !!window.netcatty?.startStreamTransfer; const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({ updateTask({
status: "transferring", status: "transferring",
@@ -1547,22 +1567,22 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let sourceStat: { size: number; mtime: number } | null = null; let sourceStat: { size: number; mtime: number } | null = null;
// Get source file stat for accurate size and mtime // Get source file stat for accurate size and mtime
try { try {
if (sourcePane.connection?.isLocal) { if (sourcePane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.sourcePath); const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) { if (stat) {
sourceStat = { sourceStat = {
size: stat.size, size: stat.size,
mtime: stat.lastModified || Date.now(), mtime: stat.lastModified || Date.now(),
}; };
} }
} else if (sourceSftpId && window.netcatty?.statSftp) { } else if (sourceSftpId && netcattyBridge.get()?.statSftp) {
const stat = await window.netcatty.statSftp( const stat = await netcattyBridge.get()!.statSftp!(
sourceSftpId, sourceSftpId,
task.sourcePath, task.sourcePath,
); );
if (stat) { if (stat) {
sourceStat = { sourceStat = {
size: stat.size, size: stat.size,
mtime: stat.lastModified || Date.now(), mtime: stat.lastModified || Date.now(),
}; };
@@ -1573,24 +1593,24 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
} }
// Get target file stat to check for conflict // Get target file stat to check for conflict
try { try {
if (targetPane.connection?.isLocal) { if (targetPane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.targetPath); const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) { if (stat) {
targetExists = true; targetExists = true;
existingStat = { existingStat = {
size: stat.size, size: stat.size,
mtime: stat.lastModified || Date.now(), mtime: stat.lastModified || Date.now(),
}; };
} }
} else if (targetSftpId && window.netcatty?.statSftp) { } else if (targetSftpId && netcattyBridge.get()?.statSftp) {
const stat = await window.netcatty.statSftp( const stat = await netcattyBridge.get()!.statSftp!(
targetSftpId, targetSftpId,
task.targetPath, task.targetPath,
); );
if (stat) { if (stat) {
targetExists = true; targetExists = true;
existingStat = { existingStat = {
size: stat.size, size: stat.size,
mtime: stat.lastModified || Date.now(), mtime: stat.lastModified || Date.now(),
}; };
@@ -1687,12 +1707,12 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
targetSftpId: string | null, targetSftpId: string | null,
sourceIsLocal: boolean, sourceIsLocal: boolean,
targetIsLocal: boolean, targetIsLocal: boolean,
): Promise<void> => { ): Promise<void> => {
// Try to use streaming transfer if available // Try to use streaming transfer if available
if (window.netcatty?.startStreamTransfer) { if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const options = { const options = {
transferId: task.id, transferId: task.id,
sourcePath: task.sourcePath, sourcePath: task.sourcePath,
targetPath: task.targetPath, targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const), sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
@@ -1726,70 +1746,70 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
resolve(); resolve();
}; };
const onError = (error: string) => { const onError = (error: string) => {
reject(new Error(error)); reject(new Error(error));
}; };
window.netcatty!.startStreamTransfer!( netcattyBridge.require().startStreamTransfer!(
options, options,
onProgress, onProgress,
onComplete, onComplete,
onError, onError,
).catch(reject); ).catch(reject);
}); });
} }
// Fallback to legacy transfer (read all then write all) // Fallback to legacy transfer (read all then write all)
let content: ArrayBuffer | string; let content: ArrayBuffer | string;
// Read from source // Read from source
if (sourceIsLocal) { if (sourceIsLocal) {
content = content =
(await window.netcatty?.readLocalFile?.(task.sourcePath)) || (await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0); new ArrayBuffer(0);
} else if (sourceSftpId) { } else if (sourceSftpId) {
if (window.netcatty?.readSftpBinary) { if (netcattyBridge.get()?.readSftpBinary) {
content = await window.netcatty.readSftpBinary( content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId, sourceSftpId,
task.sourcePath, task.sourcePath,
); );
} else { } else {
content = content =
(await window.netcatty?.readSftp(sourceSftpId, task.sourcePath)) || ""; (await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath)) || "";
} }
} else { } else {
throw new Error("No source connection"); throw new Error("No source connection");
} }
// Write to target // Write to target
if (targetIsLocal) { if (targetIsLocal) {
if (content instanceof ArrayBuffer) { if (content instanceof ArrayBuffer) {
await window.netcatty?.writeLocalFile?.(task.targetPath, content); await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else { } else {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
await window.netcatty?.writeLocalFile?.( await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath, task.targetPath,
encoder.encode(content).buffer, encoder.encode(content).buffer,
); );
} }
} else if (targetSftpId) { } else if (targetSftpId) {
if (content instanceof ArrayBuffer && window.netcatty?.writeSftpBinary) { if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await window.netcatty.writeSftpBinary( await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId, targetSftpId,
task.targetPath, task.targetPath,
content, content,
); );
} else { } else {
const text = const text =
content instanceof ArrayBuffer content instanceof ArrayBuffer
? new TextDecoder().decode(content) ? new TextDecoder().decode(content)
: content; : content;
await window.netcatty?.writeSftp(targetSftpId, task.targetPath, text); await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text);
} }
} else { } else {
throw new Error("No target connection"); throw new Error("No target connection");
} }
}; };
// Transfer a directory // Transfer a directory
const transferDirectory = async ( const transferDirectory = async (
@@ -1799,12 +1819,12 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
sourceIsLocal: boolean, sourceIsLocal: boolean,
targetIsLocal: boolean, targetIsLocal: boolean,
) => { ) => {
// Create target directory // Create target directory
if (targetIsLocal) { if (targetIsLocal) {
await window.netcatty?.mkdirLocal?.(task.targetPath); await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) { } else if (targetSftpId) {
await window.netcatty?.mkdirSftp(targetSftpId, task.targetPath); await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath);
} }
// List source directory // List source directory
let files: SftpFileEntry[]; let files: SftpFileEntry[];
@@ -1873,14 +1893,14 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
// Remove from conflicts if present // Remove from conflicts if present
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId)); setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
// Cancel at backend level if streaming transfer is in progress // Cancel at backend level if streaming transfer is in progress
if (window.netcatty?.cancelTransfer) { if (netcattyBridge.get()?.cancelTransfer) {
try { try {
await window.netcatty.cancelTransfer(transferId); await netcattyBridge.get()!.cancelTransfer!(transferId);
} catch (err) { } catch (err) {
console.warn("Failed to cancel transfer at backend:", err); logger.warn("Failed to cancel transfer at backend:", err);
} }
} }
}, },
[stopProgressSimulation], [stopProgressSimulation],
); );
@@ -2028,25 +2048,25 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
) => { ) => {
const pane = side === "left" ? leftPane : rightPane; const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection || pane.connection.isLocal) { if (!pane.connection || pane.connection.isLocal) {
console.warn("Cannot change permissions on local files"); logger.warn("Cannot change permissions on local files");
return; return;
} }
const sftpId = sftpSessionsRef.current.get(pane.connection.id); const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !window.netcatty?.chmodSftp) { if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found")); handleSessionError(side, new Error("SFTP session not found"));
return; return;
} }
try { try {
await window.netcatty.chmodSftp(sftpId, filePath, mode); await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode);
await refresh(side); await refresh(side);
} catch (err) { } catch (err) {
if (isSessionError(err)) { if (isSessionError(err)) {
handleSessionError(side, err as Error); handleSessionError(side, err as Error);
return; return;
} }
console.error("Failed to change permissions:", err); logger.error("Failed to change permissions:", err);
} }
}, },
[leftPane, rightPane, refresh, handleSessionError], [leftPane, rightPane, refresh, handleSessionError],

View File

@@ -0,0 +1,65 @@
import { useCallback, useState } from "react";
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
export type SyncStatus = "idle" | "success" | "error";
export const useSyncState = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const resetSyncStatus = useCallback(() => {
setSyncStatus("idle");
}, []);
const verify = useCallback(async (token: string, gistId?: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
if (gistId) {
await loadFromGist(token, gistId);
}
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
const upload = useCallback(
async <T extends object>(token: string, gistId: string | undefined, data: T) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const newGistId = await syncToGist(token, gistId, data);
setSyncStatus("success");
return newGistId;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
},
[],
);
const download = useCallback(async (token: string, gistId: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const data = await loadFromGist(token, gistId);
setSyncStatus("success");
return data;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
};

View File

@@ -0,0 +1,122 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTerminalBackend = () => {
const telnetAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startTelnetSession;
}, []);
const moshAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startMoshSession;
}, []);
const localAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startLocalSession;
}, []);
const execAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.execCommand;
}, []);
const startSSHSession = useCallback(async (options: NetcattySSHOptions) => {
const bridge = netcattyBridge.get();
if (!bridge?.startSSHSession) throw new Error("startSSHSession unavailable");
return bridge.startSSHSession(options);
}, []);
const startTelnetSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable");
return bridge.startTelnetSession(options);
}, []);
const startMoshSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startMoshSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startMoshSession) throw new Error("startMoshSession unavailable");
return bridge.startMoshSession(options);
}, []);
const startLocalSession = useCallback(async (options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.startLocalSession) throw new Error("startLocalSession unavailable");
return bridge.startLocalSession(options);
}, []);
const execCommand = useCallback(async (options: Parameters<NetcattyBridge["execCommand"]>[0]) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
return bridge.execCommand(options);
}, []);
const writeToSession = useCallback((sessionId: string, data: string) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data);
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
const bridge = netcattyBridge.get();
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
return bridge.onSessionData(sessionId, cb);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);
const openExternal = useCallback(async (url: string) => {
const bridge = netcattyBridge.get();
await bridge?.openExternal?.(url);
}, []);
const openExternalAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.openExternal;
}, []);
const backendAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.startSSHSession;
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
execCommand,
writeToSession,
resizeSession,
closeSession,
onSessionData,
onSessionExit,
onChainProgress,
openExternal,
};
};

View File

@@ -0,0 +1,44 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useWindowControls = () => {
const closeSettingsWindow = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.closeSettingsWindow?.();
}, []);
const openSettingsWindow = useCallback(async () => {
const bridge = netcattyBridge.get();
return bridge?.openSettingsWindow?.();
}, []);
const minimize = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.windowMinimize?.();
}, []);
const maximize = useCallback(async () => {
const bridge = netcattyBridge.get();
return bridge?.windowMaximize?.();
}, []);
const close = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.windowClose?.();
}, []);
const isMaximized = useCallback(async () => {
const bridge = netcattyBridge.get();
return bridge?.windowIsMaximized?.();
}, []);
return {
closeSettingsWindow,
openSettingsWindow,
minimize,
maximize,
close,
isMaximized,
};
};

View File

@@ -1,4 +1,4 @@
import { import {
BadgeCheck, BadgeCheck,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -15,8 +15,10 @@
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types"; import { Host, Identity, KeyType, SSHKey } from "../types";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel"; import SelectHostPanel from "./SelectHostPanel";
import { AsidePanel, AsidePanelContent } from "./ui/aside-panel"; import { AsidePanel, AsidePanelContent } from "./ui/aside-panel";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -77,6 +79,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
onSaveHost, onSaveHost,
onCreateGroup, onCreateGroup,
}) => { }) => {
const { generateKeyPair, execCommand } = useKeychainBackend();
const [activeFilter, setActiveFilter] = useState<FilterTab>("key"); const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -244,7 +247,7 @@ echo $3 >> "$FILE"`);
await navigator.clipboard.writeText(key.publicKey); await navigator.clipboard.writeText(key.publicKey);
// Could add toast notification here // Could add toast notification here
} catch (err) { } catch (err) {
console.error("Failed to copy public key:", err); logger.error("Failed to copy public key:", err);
} }
} }
}, []); }, []);
@@ -328,16 +331,19 @@ echo $3 >> "$FILE"`);
const keySize = draftKey.keySize; const keySize = draftKey.keySize;
// Use real key generation via Electron backend // Use real key generation via Electron backend
if (window.netcatty?.generateKeyPair) { const result = await generateKeyPair({
const result = await window.netcatty.generateKeyPair({ type: keyType,
type: keyType, bits: keySize,
bits: keySize, comment: `${draftKey.label.trim()}@netcatty`,
comment: `${draftKey.label.trim()}@netcatty`, });
}); if (!result) {
throw new Error(
if (!result.success || !result.privateKey || !result.publicKey) { "Key generation not available - please ensure the app is running in Electron",
throw new Error(result.error || "Failed to generate key pair"); );
} }
if (!result.success || !result.privateKey || !result.publicKey) {
throw new Error(result.error || "Failed to generate key pair");
}
const newKey: SSHKey = { const newKey: SSHKey = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -355,17 +361,12 @@ echo $3 >> "$FILE"`);
onSave(newKey); onSave(newKey);
closePanel(); closePanel();
} else {
throw new Error(
"Key generation not available - please ensure the app is running in Electron",
);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate key"); setError(err instanceof Error ? err.message : "Failed to generate key");
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}, [draftKey, onSave, closePanel]); }, [draftKey, onSave, closePanel, generateKeyPair]);
// Handle biometric key generation (Windows Hello) // Handle biometric key generation (Windows Hello)
const handleGenerateBiometric = useCallback(async () => { const handleGenerateBiometric = useCallback(async () => {
@@ -1235,7 +1236,7 @@ echo $3 >> "$FILE"`);
const command = scriptWithVars; const command = scriptWithVars;
// Execute via SSH // Execute via SSH
const result = await window.netcatty?.execCommand({ const result = await execCommand({
hostname: exportHost.hostname, hostname: exportHost.hostname,
username: exportHost.username, username: exportHost.username,
port: exportHost.port || 22, port: exportHost.port || 22,

View File

@@ -1,4 +1,4 @@
import { import {
ArrowRight, ArrowRight,
ChevronDown, ChevronDown,
Clock, Clock,
@@ -21,6 +21,8 @@ import React, {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Host, KnownHost } from "../types"; import { Host, KnownHost } from "../types";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -104,7 +106,7 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
discoveredAt: Date.now(), discoveredAt: Date.now(),
}); });
} catch { } catch {
console.warn("Failed to parse known_hosts line:", line); logger.warn("Failed to parse known_hosts line:", line);
} }
} }
@@ -259,13 +261,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
onImportFromFile, onImportFromFile,
onRefresh, onRefresh,
}) => { }) => {
// Debug: track renders const { readKnownHosts } = useKnownHostsBackend();
const renderCountRef = React.useRef(0);
renderCountRef.current++;
console.log(
`[KnownHostsManager] render #${renderCountRef.current} - knownHosts: ${knownHosts.length}, hosts: ${hosts.length}`,
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search); const deferredSearch = useDeferredValue(search);
const [isScanning, setIsScanning] = useState(false); const [isScanning, setIsScanning] = useState(false);
@@ -278,31 +274,28 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
// Define handleScanSystem before useEffect that depends on it // Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async () => { const handleScanSystem = useCallback(async () => {
setIsScanning(true); setIsScanning(true);
// Try to read from common known_hosts locations via Electron try {
if (window.netcatty?.readKnownHosts) { const content = await readKnownHosts();
try { if (content) {
const content = await window.netcatty.readKnownHosts(); const parsed = parseKnownHostsFile(content);
if (content) { const existingHostnames = new Set(
const parsed = parseKnownHostsFile(content); knownHosts.map((h) => `${h.hostname}:${h.port}`),
const existingHostnames = new Set( );
knownHosts.map((h) => `${h.hostname}:${h.port}`), const newHosts = parsed.filter(
); (h) => !existingHostnames.has(`${h.hostname}:${h.port}`),
const newHosts = parsed.filter( );
(h) => !existingHostnames.has(`${h.hostname}:${h.port}`),
);
// Directly import new hosts without dialog // Directly import new hosts without dialog
if (newHosts.length > 0) { if (newHosts.length > 0) {
onImportFromFile(newHosts); onImportFromFile(newHosts);
}
} }
} catch (err) {
console.error("Failed to scan system known_hosts:", err);
} }
} catch (err) {
logger.error("Failed to scan system known_hosts:", err);
} }
onRefresh(); onRefresh();
setIsScanning(false); setIsScanning(false);
}, [knownHosts, onRefresh, onImportFromFile]); }, [knownHosts, onRefresh, onImportFromFile, readKnownHosts]);
// Auto-scan on first mount // Auto-scan on first mount
useEffect(() => { useEffect(() => {
@@ -423,10 +416,6 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
// Memoize the rendered list to prevent re-renders // Memoize the rendered list to prevent re-renders
const renderedItems = useMemo(() => { const renderedItems = useMemo(() => {
console.log(
"[KnownHostsManager] renderedItems useMemo recalculated - displayedHosts:",
displayedHosts.length,
);
return displayedHosts.map((knownHost) => ( return displayedHosts.map((knownHost) => (
<HostItem <HostItem
key={knownHost.id} key={knownHost.id}
@@ -445,8 +434,6 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
handleConvertToHost, handleConvertToHost,
]); ]);
console.log("[KnownHostsManager] about to return JSX");
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}

View File

@@ -17,10 +17,6 @@ import {
PortForwardingType, PortForwardingType,
SSHKey, SSHKey,
} from "../domain/models"; } from "../domain/models";
import {
startPortForward,
stopPortForward,
} from "../infrastructure/services/portForwardingService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel"; import SelectHostPanel from "./SelectHostPanel";
import { import {
@@ -83,8 +79,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
deleteRule, deleteRule,
duplicateRule, duplicateRule,
setRuleStatus, setRuleStatus,
startTunnel,
stopTunnel,
filteredRules, filteredRules,
selectedRule: _selectedRule, selectedRule: _selectedRule,
preferFormMode,
setPreferFormMode,
} = usePortForwardingState(); } = usePortForwardingState();
// Track connecting/stopping states // Track connecting/stopping states
@@ -106,12 +106,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
let errorShown = false; let errorShown = false;
try { try {
const result = await startPortForward( const result = await startTunnel(
rule, rule,
_host, _host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })), keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
(status, error) => { (status, error) => {
setRuleStatus(rule.id, status, error);
// Show toast on error (only once) // Show toast on error (only once)
if (status === "error" && error && !errorShown) { if (status === "error" && error && !errorShown) {
errorShown = true; errorShown = true;
@@ -132,7 +131,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}); });
} }
}, },
[hosts, keys, setRuleStatus], [hosts, keys, setRuleStatus, startTunnel],
); );
// Stop a port forwarding tunnel // Stop a port forwarding tunnel
@@ -141,9 +140,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
setPendingOperations((prev) => new Set([...prev, rule.id])); setPendingOperations((prev) => new Set([...prev, rule.id]));
try { try {
await stopPortForward(rule.id, (status) => { await stopTunnel(rule.id);
setRuleStatus(rule.id, status);
});
} finally { } finally {
setPendingOperations((prev) => { setPendingOperations((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -152,7 +149,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}); });
} }
}, },
[setRuleStatus], [stopTunnel],
); );
// Wizard state // Wizard state
@@ -191,15 +188,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
hostId: undefined, hostId: undefined,
}, },
); );
// User preference: prefer wizard (false) or form (true)
const [preferFormMode, setPreferFormMode] = useState(() => {
try {
// Default to wizard mode (false) if not set
return localStorage.getItem("pf-prefer-form-mode") === "true";
} catch {
return false;
}
});
// New forwarding menu // New forwarding menu
const [showNewMenu, setShowNewMenu] = useState(false); const [showNewMenu, setShowNewMenu] = useState(false);
@@ -258,11 +246,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const skipWizardToForm = () => { const skipWizardToForm = () => {
// Save preference // Save preference
setPreferFormMode(true); setPreferFormMode(true);
try {
localStorage.setItem("pf-prefer-form-mode", "true");
} catch {
// Ignore localStorage errors (e.g., private browsing mode)
}
// Transfer current draft to form // Transfer current draft to form
setNewFormDraft({ setNewFormDraft({
@@ -277,11 +260,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const openWizardFromForm = () => { const openWizardFromForm = () => {
// User opens wizard - prefer wizard mode next time // User opens wizard - prefer wizard mode next time
setPreferFormMode(false); setPreferFormMode(false);
try {
localStorage.setItem("pf-prefer-form-mode", "false");
} catch {
// Ignore localStorage errors (e.g., private browsing mode)
}
// Transfer current form draft to wizard // Transfer current form draft to wizard
setWizardType(newFormDraft.type || "local"); setWizardType(newFormDraft.type || "local");

View File

@@ -1,4 +1,4 @@
import { import {
ArrowUp, ArrowUp,
ChevronRight, ChevronRight,
Database, Database,
@@ -33,6 +33,8 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types"; import { Host, RemoteFile } from "../types";
import { DistroAvatar } from "./DistroAvatar"; import { DistroAvatar } from "./DistroAvatar";
@@ -196,7 +198,7 @@ const getFileIcon = (fileName: string, isDirectory: boolean) => {
// Default // Default
return <File size={18} className={iconClass} />; return <File size={18} className={iconClass} />;
}; };
// Format bytes with appropriate unit (B, KB, MB, GB) // Format bytes with appropriate unit (B, KB, MB, GB)
const formatBytes = (bytes: number | string): string => { const formatBytes = (bytes: number | string): string => {
@@ -254,6 +256,17 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
open, open,
onClose, onClose,
}) => { }) => {
const {
openSftp,
closeSftp: closeSftpBackend,
listSftp,
readSftp,
writeSftpBinaryWithProgress,
writeSftpBinary,
writeSftp,
deleteSftp,
mkdirSftp,
} = useSftpBackend();
const [currentPath, setCurrentPath] = useState("/"); const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]); const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -299,8 +312,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const ensureSftp = useCallback(async () => { const ensureSftp = useCallback(async () => {
if (sftpIdRef.current) return sftpIdRef.current; if (sftpIdRef.current) return sftpIdRef.current;
if (!window.netcatty?.openSftp) throw new Error("SFTP bridge unavailable"); const sftpId = await openSftp({
const sftpId = await window.netcatty.openSftp({
sessionId: `sftp-modal-${host.id}`, sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname, hostname: credentials.hostname,
username: credentials.username || "root", username: credentials.username || "root",
@@ -317,6 +329,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials.port, credentials.port,
credentials.password, credentials.password,
credentials.privateKey, credentials.privateKey,
openSftp,
]); ]);
const loadFiles = useCallback( const loadFiles = useCallback(
@@ -343,7 +356,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setError(null); setError(null);
setLoading(true); setLoading(true);
const sftpId = await ensureSftp(); const sftpId = await ensureSftp();
const list = await window.netcatty.listSftp(sftpId, path); const list = await listSftp(sftpId, path);
if (loadSeqRef.current !== requestId) return; if (loadSeqRef.current !== requestId) return;
dirCacheRef.current.set(cacheKey, { dirCacheRef.current.set(cacheKey, {
files: list, files: list,
@@ -353,7 +366,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setSelectedFiles(new Set()); setSelectedFiles(new Set());
} catch (e) { } catch (e) {
if (loadSeqRef.current !== requestId) return; if (loadSeqRef.current !== requestId) return;
console.error("Failed to load files", e); logger.error("Failed to load files", e);
setError(e instanceof Error ? e.message : "Failed to load directory"); setError(e instanceof Error ? e.message : "Failed to load directory");
setFiles([]); setFiles([]);
} finally { } finally {
@@ -362,26 +375,26 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
} }
} }
}, },
[ensureSftp, host.id], [ensureSftp, host.id, listSftp],
); );
const closeSftp = async () => { const closeSftpSession = useCallback(async () => {
if (sftpIdRef.current && window.netcatty?.closeSftp) { if (sftpIdRef.current) {
try { try {
await window.netcatty.closeSftp(sftpIdRef.current); await closeSftpBackend(sftpIdRef.current);
} catch { } catch {
// Silently ignore close errors - connection may already be closed // Silently ignore close errors - connection may already be closed
} }
} }
sftpIdRef.current = null; sftpIdRef.current = null;
}; }, [closeSftpBackend]);
useEffect(() => { useEffect(() => {
return () => { return () => {
// Cleanup on unmount // Cleanup on unmount
closeSftp(); void closeSftpSession();
}; };
}, []); }, [closeSftpSession]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -393,10 +406,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
} else { } else {
// Invalidate any in-flight directory load // Invalidate any in-flight directory load
loadSeqRef.current += 1; loadSeqRef.current += 1;
closeSftp(); void closeSftpSession();
initializedRef.current = false; initializedRef.current = false;
} }
}, [open, currentPath, loadFiles]); }, [open, currentPath, loadFiles, closeSftpSession]);
const handleNavigate = useCallback((path: string) => { const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition) // Prevent double navigation (e.g., from double-click race condition)
@@ -415,28 +428,31 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setCurrentPath(parent); setCurrentPath(parent);
}; };
const handleDownload = async (file: RemoteFile) => { const handleDownload = useCallback(
try { async (file: RemoteFile) => {
const sftpId = await ensureSftp(); try {
const fullPath = const sftpId = await ensureSftp();
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`; const fullPath =
setLoading(true); currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
const content = await window.netcatty.readSftp(sftpId, fullPath); setLoading(true);
const blob = new Blob([content], { type: "application/octet-stream" }); const content = await readSftp(sftpId, fullPath);
const url = URL.createObjectURL(blob); const blob = new Blob([content], { type: "application/octet-stream" });
const a = document.createElement("a"); const url = URL.createObjectURL(blob);
a.href = url; const a = document.createElement("a");
a.download = file.name; a.href = url;
document.body.appendChild(a); a.download = file.name;
a.click(); document.body.appendChild(a);
document.body.removeChild(a); a.click();
URL.revokeObjectURL(url); document.body.removeChild(a);
} catch (e) { URL.revokeObjectURL(url);
setError(e instanceof Error ? e.message : "Download failed"); } catch (e) {
} finally { setError(e instanceof Error ? e.message : "Download failed");
setLoading(false); } finally {
} setLoading(false);
}; }
},
[currentPath, ensureSftp, readSftp],
);
const handleUploadFile = async ( const handleUploadFile = async (
file: File, file: File,
@@ -466,70 +482,69 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`; currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
// Use real-time progress API if available // Use real-time progress API if available
if (window.netcatty.writeSftpBinaryWithProgress) { const progressResult = await writeSftpBinaryWithProgress(
await window.netcatty.writeSftpBinaryWithProgress( sftpId,
sftpId, fullPath,
fullPath, arrayBuffer,
arrayBuffer, taskId,
taskId, // Real-time progress callback
// Real-time progress callback (transferred: number, total: number, speed: number) => {
(transferred: number, total: number, speed: number) => { const progress = total > 0 ? Math.round((transferred / total) * 100) : 0;
const progress = setUploadTasks((prev) =>
total > 0 ? Math.round((transferred / total) * 100) : 0; prev.map((t) =>
setUploadTasks((prev) => t.id === taskId && t.status === "uploading"
prev.map((t) => ? {
t.id === taskId && t.status === "uploading" ...t,
? { transferredBytes: transferred,
...t, progress,
transferredBytes: transferred, speed,
progress, }
speed, : t,
} ),
: t, );
), },
); // Complete callback
}, () => {
// Complete callback const totalTime = (Date.now() - startTime) / 1000;
() => { const finalSpeed = totalTime > 0 ? file.size / totalTime : 0;
const totalTime = (Date.now() - startTime) / 1000; setUploadTasks((prev) =>
const finalSpeed = totalTime > 0 ? file.size / totalTime : 0; prev.map((t) =>
setUploadTasks((prev) => t.id === taskId
prev.map((t) => ? {
t.id === taskId ...t,
? { status: "completed" as const,
...t, progress: 100,
status: "completed" as const, transferredBytes: file.size,
progress: 100, speed: finalSpeed,
transferredBytes: file.size, }
speed: finalSpeed, : t,
} ),
: t, );
), },
); // Error callback
}, (error: string) => {
// Error callback setUploadTasks((prev) =>
(error: string) => { prev.map((t) =>
setUploadTasks((prev) => t.id === taskId
prev.map((t) => ? {
t.id === taskId ...t,
? { status: "failed" as const,
...t, error,
status: "failed" as const, }
error, : t,
} ),
: t, );
), },
); );
}, if (progressResult) return true;
);
return true; try {
} else if (window.netcatty.writeSftpBinary) {
// Fallback to non-progress API // Fallback to non-progress API
await window.netcatty.writeSftpBinary(sftpId, fullPath, arrayBuffer); await writeSftpBinary(sftpId, fullPath, arrayBuffer);
} else { } catch {
// Fallback: read as text (works for text files) // Fallback: read as text (works for text files)
const text = await file.text(); const text = await file.text();
await window.netcatty.writeSftp(sftpId, fullPath, text); await writeSftp(sftpId, fullPath, text);
} }
// Calculate final speed (for fallback methods) // Calculate final speed (for fallback methods)
@@ -607,9 +622,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const fullPath = const fullPath =
currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`; currentPath === "/" ? `/${file.name}` : `${currentPath}/${file.name}`;
// Use deleteSftp which handles both files and directories // Use deleteSftp which handles both files and directories
if (window.netcatty.deleteSftp) { await deleteSftp(sftpId, fullPath);
await window.netcatty.deleteSftp(sftpId, fullPath);
}
await loadFiles(currentPath, { force: true }); await loadFiles(currentPath, { force: true });
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Delete failed"); setError(e instanceof Error ? e.message : "Delete failed");
@@ -623,7 +636,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const sftpId = await ensureSftp(); const sftpId = await ensureSftp();
const fullPath = const fullPath =
currentPath === "/" ? `/${folderName}` : `${currentPath}/${folderName}`; currentPath === "/" ? `/${folderName}` : `${currentPath}/${folderName}`;
await window.netcatty.mkdirSftp(sftpId, fullPath); await mkdirSftp(sftpId, fullPath);
await loadFiles(currentPath, { force: true }); await loadFiles(currentPath, { force: true });
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to create folder"); setError(e instanceof Error ? e.message : "Failed to create folder");
@@ -659,7 +672,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}; };
const handleClose = async () => { const handleClose = async () => {
await closeSftp(); await closeSftpSession();
setIsEditingPath(false); setIsEditingPath(false);
onClose(); onClose();
}; };
@@ -852,9 +865,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
for (const fileName of fileNames) { for (const fileName of fileNames) {
const fullPath = const fullPath =
currentPath === "/" ? `/${fileName}` : `${currentPath}/${fileName}`; currentPath === "/" ? `/${fileName}` : `${currentPath}/${fileName}`;
if (window.netcatty.deleteSftp) { await deleteSftp(sftpId, fullPath);
await window.netcatty.deleteSftp(sftpId, fullPath);
}
} }
await loadFiles(currentPath, { force: true }); await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set()); setSelectedFiles(new Set());

View File

@@ -14,16 +14,15 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useSyncState } from "../application/state/useSyncState";
import { Host, SSHKey, Snippet, TerminalSettings, CursorShape, RightClickBehavior, LinkModifier, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, KeyBinding, keyEventToString } from "../domain/models"; import { Host, SSHKey, Snippet, TerminalSettings, CursorShape, RightClickBehavior, LinkModifier, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, KeyBinding, keyEventToString } from "../domain/models";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes"; import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { TERMINAL_FONTS, MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts"; import { TERMINAL_FONTS, MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { import { logger } from "../lib/logger";
loadFromGist,
syncToGist,
} from "../infrastructure/services/syncService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { SyncConfig } from "../types"; import { SyncConfig } from "../types";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { toast } from "./ui/toast";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -268,13 +267,15 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({
// Sync State // Sync State
const [githubToken, setGithubToken] = useState(syncConfig?.githubToken || ""); const [githubToken, setGithubToken] = useState(syncConfig?.githubToken || "");
const [gistId, setGistId] = useState(syncConfig?.gistId || ""); const [gistId, setGistId] = useState(syncConfig?.gistId || "");
const [isSyncing, setIsSyncing] = useState(false); const { isSyncing, syncStatus, resetSyncStatus, verify, upload, download } =
const [syncStatus, setSyncStatus] = useState<"idle" | "success" | "error">( useSyncState();
"idle",
);
const isMac = hotkeyScheme === 'mac'; const isMac = hotkeyScheme === 'mac';
useEffect(() => {
if (isOpen) resetSyncStatus();
}, [isOpen, resetSyncStatus]);
// Handle key recording for shortcut editing // Handle key recording for shortcut editing
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!editingBindingId) return; if (!editingBindingId) return;
@@ -368,36 +369,26 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({
try { try {
JSON.parse(importText); JSON.parse(importText);
onImport(importText); onImport(importText);
alert("Configuration imported successfully!"); toast.success("Configuration imported successfully!");
setImportText(""); setImportText("");
} catch { } catch {
alert("Invalid JSON format."); toast.error("Invalid JSON format.");
} }
}; };
const handleSaveSyncConfig = async () => { const handleSaveSyncConfig = async () => {
if (!githubToken) return; if (!githubToken) return;
setIsSyncing(true);
setSyncStatus("idle");
try { try {
if (gistId) { await verify(githubToken, gistId || undefined);
await loadFromGist(githubToken, gistId);
}
onSyncConfigChange({ githubToken, gistId }); onSyncConfigChange({ githubToken, gistId });
setSyncStatus("success");
} catch (e) { } catch (e) {
console.error(e); logger.error(e);
setSyncStatus("error"); toast.error("Failed to verify Gist or Token.");
alert("Failed to verify Gist or Token.");
} finally {
setIsSyncing(false);
} }
}; };
const performSyncUpload = async () => { const performSyncUpload = async () => {
if (!githubToken) return; if (!githubToken) return;
setIsSyncing(true);
try { try {
const data = exportData() as { const data = exportData() as {
keys: SSHKey[]; keys: SSHKey[];
@@ -405,11 +396,7 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({
snippets: Snippet[]; snippets: Snippet[];
customGroups: string[]; customGroups: string[];
}; };
const newGistId = await syncToGist( const newGistId = await upload(githubToken, gistId || undefined, data);
githubToken,
gistId || undefined,
data,
);
if (!gistId) { if (!gistId) {
setGistId(newGistId); setGistId(newGistId);
onSyncConfigChange({ onSyncConfigChange({
@@ -420,26 +407,21 @@ const SettingsDialog: React.FC<SettingsDialogProps> = ({
} else { } else {
onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() }); onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() });
} }
alert("Backup uploaded to Gist successfully!"); toast.success("Backup uploaded to Gist successfully!");
} catch (e) { } catch (e) {
alert("Upload failed: " + e); toast.error(String(e), "Upload failed");
} finally {
setIsSyncing(false);
} }
}; };
const performSyncDownload = async () => { const performSyncDownload = async () => {
if (!githubToken || !gistId) return; if (!githubToken || !gistId) return;
setIsSyncing(true);
try { try {
const data = await loadFromGist(githubToken, gistId); const data = await download(githubToken, gistId);
onImport(JSON.stringify(data)); onImport(JSON.stringify(data));
onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() }); onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() });
alert("Configuration restored from Gist!"); toast.success("Configuration restored from Gist!");
} catch (e) { } catch (e) {
alert("Download failed: " + e); toast.error(String(e), "Download failed");
} finally {
setIsSyncing(false);
} }
}; };

View File

@@ -19,6 +19,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useSyncState } from "../application/state/useSyncState";
import { import {
CursorShape, CursorShape,
RightClickBehavior, RightClickBehavior,
@@ -30,10 +31,6 @@ import {
} from "../domain/models"; } from "../domain/models";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes"; import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { TERMINAL_FONTS, MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts"; import { TERMINAL_FONTS, MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import {
loadFromGist,
syncToGist,
} from "../infrastructure/services/syncService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
@@ -41,8 +38,10 @@ import { Label } from "./ui/label";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast";
import { useSettingsState } from "../application/state/useSettingsState"; import { useSettingsState } from "../application/state/useSettingsState";
import { useVaultState } from "../application/state/useVaultState"; import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
// More comprehensive color palette // More comprehensive color palette
const COLORS = [ const COLORS = [
@@ -265,9 +264,10 @@ export default function SettingsPage() {
exportData, exportData,
importDataFromString, importDataFromString,
} = useVaultState(); } = useVaultState();
const { closeSettingsWindow } = useWindowControls();
// Local state // Local state
const [isSyncing, setIsSyncing] = useState(false); const { isSyncing, upload, download } = useSyncState();
const [gistToken, setGistToken] = useState(syncConfig?.githubToken || ""); const [gistToken, setGistToken] = useState(syncConfig?.githubToken || "");
const [gistId, setGistId] = useState(syncConfig?.gistId || ""); const [gistId, setGistId] = useState(syncConfig?.gistId || "");
const [importText, setImportText] = useState(""); const [importText, setImportText] = useState("");
@@ -276,8 +276,8 @@ export default function SettingsPage() {
// Close window handler // Close window handler
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
window.netcatty?.closeSettingsWindow?.(); closeSettingsWindow();
}, []); }, [closeSettingsWindow]);
// Helper functions // Helper functions
const getHslStyle = (hsl: string) => ({ backgroundColor: `hsl(${hsl})` }); const getHslStyle = (hsl: string) => ({ backgroundColor: `hsl(${hsl})` });
@@ -370,41 +370,36 @@ export default function SettingsPage() {
// Sync handlers // Sync handlers
const handleSaveGist = async () => { const handleSaveGist = async () => {
if (!gistToken) return alert("Please enter a GitHub token"); if (!gistToken) return toast.error("Please enter a GitHub token");
updateSyncConfig({ githubToken: gistToken, gistId: gistId || undefined }); updateSyncConfig({ githubToken: gistToken, gistId: gistId || undefined });
setIsSyncing(true);
try { try {
const newId = await syncToGist( const newId = await upload(gistToken, gistId || undefined, {
gistToken, hosts,
gistId || undefined, keys,
{ hosts, keys, snippets, customGroups: [] } snippets,
); customGroups: [],
});
if (newId && newId !== gistId) { if (newId && newId !== gistId) {
setGistId(newId); setGistId(newId);
updateSyncConfig({ githubToken: gistToken, gistId: newId }); updateSyncConfig({ githubToken: gistToken, gistId: newId });
alert("Synced! Gist ID saved."); toast.success("Synced! Gist ID saved.");
} else { } else {
alert("Synced successfully."); toast.success("Synced successfully.");
} }
} catch (e) { } catch (e) {
alert("Sync failed: " + e); toast.error(String(e), "Sync failed");
} finally {
setIsSyncing(false);
} }
}; };
const handleLoadGist = async () => { const handleLoadGist = async () => {
if (!gistToken || !gistId) return alert("Token and Gist ID required"); if (!gistToken || !gistId) return toast.error("Token and Gist ID required");
setIsSyncing(true);
try { try {
const data = await loadFromGist(gistToken, gistId); const data = await download(gistToken, gistId);
if (!data) throw new Error("No data found in Gist"); if (!data) throw new Error("No data found in Gist");
importDataFromString(JSON.stringify(data)); importDataFromString(JSON.stringify(data));
alert("Loaded successfully!"); toast.success("Loaded successfully!");
} catch (e) { } catch (e) {
alert("Download failed: " + e); toast.error(String(e), "Download failed");
} finally {
setIsSyncing(false);
} }
}; };
@@ -1224,9 +1219,9 @@ export default function SettingsPage() {
try { try {
importDataFromString(importText); importDataFromString(importText);
setImportText(""); setImportText("");
alert("Import successful!"); toast.success("Import successful!");
} catch (e) { } catch (e) {
alert("Import failed: " + e); toast.error(String(e), "Import failed");
} }
}} }}
disabled={!importText.trim()} disabled={!importText.trim()}

View File

@@ -7,6 +7,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { Maximize2 } from "lucide-react"; import { Maximize2 } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState, useCallback } from "react"; import React, { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { import {
Host, Host,
@@ -19,6 +20,7 @@ import {
KeyBinding, KeyBinding,
} from "../types"; } from "../types";
import { checkAppShortcut, getAppLevelActions, getTerminalPassthroughActions } from "../application/state/useGlobalHotkeys"; import { checkAppShortcut, getAppLevelActions, getTerminalPassthroughActions } from "../application/state/useGlobalHotkeys";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog"; import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal"; import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -147,6 +149,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keyBindingsRef.current = keyBindings; keyBindingsRef.current = keyBindings;
onHotkeyActionRef.current = onHotkeyAction; onHotkeyActionRef.current = onHotkeyAction;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const [isScriptsOpen, setIsScriptsOpen] = useState(false); const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [status, setStatus] = useState<TerminalSession["status"]>("connecting"); const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -221,11 +226,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disposeExitRef.current?.(); disposeExitRef.current?.();
disposeExitRef.current = null; disposeExitRef.current = null;
if (sessionRef.current && window.netcatty?.closeSession) { if (sessionRef.current) {
try { try {
window.netcatty.closeSession(sessionRef.current); terminalBackend.closeSession(sessionRef.current);
} catch (err) { } catch (err) {
console.warn("Failed to close SSH session", err); logger.warn("Failed to close SSH session", err);
} }
} }
sessionRef.current = null; sessionRef.current = null;
@@ -244,9 +249,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}; };
const runDistroDetection = async (key?: SSHKey) => { const runDistroDetection = async (key?: SSHKey) => {
if (!window.netcatty?.execCommand) return; if (!terminalBackend.execAvailable()) return;
try { try {
const res = await window.netcatty.execCommand({ const res = await terminalBackend.execCommand({
hostname: host.hostname, hostname: host.hostname,
username: host.username || "root", username: host.username || "root",
port: host.port || 22, port: host.port || 22,
@@ -261,10 +266,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? idMatch[1].replace(/"/g, "") ? idMatch[1].replace(/"/g, "")
: (data.split(/\\s+/)[0] || "").toLowerCase(); : (data.split(/\\s+/)[0] || "").toLowerCase();
if (distro) onOsDetected?.(host.id, distro); if (distro) onOsDetected?.(host.id, distro);
} catch (err) { } catch (err) {
console.warn("OS probe failed", err); logger.warn("OS probe failed", err);
} }
}; };
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -390,7 +395,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? "canvas" ? "canvas"
: rendererName : rendererName
: "unknown"; : "unknown";
console.info(`[XTerm] renderer=${normalized}`); logger.info(`[XTerm] renderer=${normalized}`);
const scopedWindow = window as Window & { __xtermRenderer?: string }; const scopedWindow = window as Window & { __xtermRenderer?: string };
scopedWindow.__xtermRenderer = normalized; scopedWindow.__xtermRenderer = normalized;
if (normalized === "unknown" && attempt < 3) { if (normalized === "unknown" && attempt < 3) {
@@ -440,20 +445,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
})(); })();
webglAddon.onContextLoss(() => { webglAddon.onContextLoss(() => {
console.warn("[XTerm] WebGL context loss detected, disposing addon"); logger.warn("[XTerm] WebGL context loss detected, disposing addon");
webglAddon.dispose(); webglAddon.dispose();
}); });
term.loadAddon(webglAddon); term.loadAddon(webglAddon);
webglLoaded = true; webglLoaded = true;
} catch (webglErr) { } catch (webglErr) {
console.warn( logger.warn(
"[XTerm] WebGL addon failed, using canvas renderer. Error:", "[XTerm] WebGL addon failed, using canvas renderer. Error:",
webglErr instanceof Error ? webglErr.message : webglErr, webglErr instanceof Error ? webglErr.message : webglErr,
); );
// Canvas renderer will be used as fallback - it's actually faster on some Macs // Canvas renderer will be used as fallback - it's actually faster on some Macs
} }
} else { } else {
console.info("[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)"); logger.info(
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
);
} }
// Store whether WebGL was successfully loaded for diagnostics // Store whether WebGL was successfully loaded for diagnostics
@@ -481,8 +488,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
if (shouldOpen) { if (shouldOpen) {
// Open URL in default browser // Open URL in default browser
if (window.netcatty?.openExternal) { if (terminalBackend.openExternalAvailable()) {
window.netcatty.openExternal(uri); void terminalBackend.openExternal(uri);
} else { } else {
window.open(uri, '_blank'); window.open(uri, '_blank');
} }
@@ -518,28 +525,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isMac = currentScheme === 'mac'; const isMac = currentScheme === 'mac';
// Debug: log arrow key combinations
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && (e.ctrlKey || e.metaKey)) {
console.log('[Terminal] Arrow key combo detected:', {
key: e.key,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
metaKey: e.metaKey,
scheme: currentScheme,
bindingsCount: currentBindings.length,
});
}
// Check if this matches any of our shortcuts // Check if this matches any of our shortcuts
const matched = checkAppShortcut(e, currentBindings, isMac); const matched = checkAppShortcut(e, currentBindings, isMac);
if (!matched) return true; // Let xterm handle it if (!matched) return true; // Let xterm handle it
console.log('[Terminal] Matched hotkey:', matched.action);
const { action } = matched; const { action } = matched;
// App-level actions: call the callback directly and prevent xterm from handling // App-level actions: call the callback directly and prevent xterm from handling
if (appLevelActions.has(action)) { if (appLevelActions.has(action)) {
console.log('[Terminal] Executing app-level action:', action);
e.preventDefault(); e.preventDefault();
if (hotkeyCallback) { if (hotkeyCallback) {
hotkeyCallback(action, e); hotkeyCallback(action, e);
@@ -561,9 +554,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
case 'paste': { case 'paste': {
navigator.clipboard.readText().then((text) => { navigator.clipboard.readText().then((text) => {
const id = sessionRef.current; const id = sessionRef.current;
if (id && window.netcatty?.writeToSession) { if (id) terminalBackend.writeToSession(id, text);
window.netcatty.writeToSession(id, text);
}
}); });
break; break;
} }
@@ -593,16 +584,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleMiddleClick = async (e: MouseEvent) => { const handleMiddleClick = async (e: MouseEvent) => {
if (e.button === 1) { // Middle mouse button if (e.button === 1) { // Middle mouse button
e.preventDefault(); e.preventDefault();
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (text && sessionRef.current && window.netcatty?.writeToSession) { if (text && sessionRef.current) {
window.netcatty.writeToSession(sessionRef.current, text); terminalBackend.writeToSession(sessionRef.current, text);
} }
} catch (err) { } catch (err) {
console.warn('[Terminal] Failed to paste from clipboard:', err); logger.warn('[Terminal] Failed to paste from clipboard:', err);
} }
} }
}; };
containerRef.current.addEventListener('auxclick', handleMiddleClick); containerRef.current.addEventListener('auxclick', handleMiddleClick);
// Store cleanup function // Store cleanup function
const container = containerRef.current; const container = containerRef.current;
@@ -617,15 +608,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fitAddon.fit(); fitAddon.fit();
term.focus(); term.focus();
} catch (openErr) { } catch (openErr) {
console.error("[XTerm] Failed to open terminal:", openErr); logger.error("[XTerm] Failed to open terminal:", openErr);
throw openErr; throw openErr;
} }
term.onData((data) => { term.onData((data) => {
const id = sessionRef.current; const id = sessionRef.current;
if (id && window.netcatty?.writeToSession) { if (id) {
window.netcatty.writeToSession(id, data); terminalBackend.writeToSession(id, data);
// Track command input for shell history // Track command input for shell history
if (status === "connected" && onCommandExecuted) { if (status === "connected" && onCommandExecuted) {
@@ -663,19 +654,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Add debouncing for resize events to prevent excessive calls on macOS // Add debouncing for resize events to prevent excessive calls on macOS
let resizeTimeout: NodeJS.Timeout | null = null; let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs; const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => { term.onResize(({ cols, rows }) => {
const id = sessionRef.current; const id = sessionRef.current;
if (id && window.netcatty?.resizeSession) { if (id) {
// Debounce resize to prevent rapid successive calls // Debounce resize to prevent rapid successive calls
if (resizeTimeout) { if (resizeTimeout) {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
} }
resizeTimeout = setTimeout(() => { resizeTimeout = setTimeout(() => {
window.netcatty.resizeSession(id, cols, rows); terminalBackend.resizeSession(id, cols, rows);
resizeTimeout = null; resizeTimeout = null;
}, resizeDebounceMs); // Debounce for smooth resizing on macOS }, resizeDebounceMs); // Debounce for smooth resizing on macOS
} }
}); });
if (host.protocol === "local" || host.hostname === "localhost") { if (host.protocol === "local" || host.hostname === "localhost") {
setStatus("connecting"); setStatus("connecting");
@@ -709,7 +700,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await startSSH(term); await startSSH(term);
} }
} catch (err) { } catch (err) {
console.error("Failed to initialize terminal", err); logger.error("Failed to initialize terminal", err);
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
updateStatus("disconnected"); updateStatus("disconnected");
} }
@@ -784,7 +775,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
fitAddon.fit(); fitAddon.fit();
} catch (err) { } catch (err) {
console.warn("Fit failed", err); logger.warn("Fit failed", err);
} }
}; };
@@ -889,33 +880,33 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
term?.renderer?.remeasureFont?.(); term?.renderer?.remeasureFont?.();
} catch (err) { } catch (err) {
console.warn("Font remeasure failed", err); logger.warn("Font remeasure failed", err);
} }
try { try {
fitAddon?.fit(); fitAddon?.fit();
} catch (err) { } catch (err) {
console.warn("Fit after fonts ready failed", err); logger.warn("Fit after fonts ready failed", err);
} }
const id = sessionRef.current; const id = sessionRef.current;
if (id && term && window.netcatty?.resizeSession) { if (id && term) {
try { try {
window.netcatty.resizeSession(id, term.cols, term.rows); resizeSession(id, term.cols, term.rows);
} catch (err) { } catch (err) {
console.warn("Resize session after fonts ready failed", err); logger.warn("Resize session after fonts ready failed", err);
} }
} }
} catch (err) { } catch (err) {
console.warn("Waiting for fonts failed", err); logger.warn("Waiting for fonts failed", err);
} }
}; };
waitForFonts(); waitForFonts();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [host.id, sessionId]); }, [host.id, sessionId, resizeSession]);
// Debounced fit for resize operations - only fit when not actively resizing // Debounced fit for resize operations - only fit when not actively resizing
useEffect(() => { useEffect(() => {
@@ -987,7 +978,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Small delay to ensure state updates complete // Small delay to ensure state updates complete
const timer = setTimeout(() => { const timer = setTimeout(() => {
termRef.current?.focus(); termRef.current?.focus();
console.log('[Terminal] Focus triggered via isFocused prop, sessionId:', sessionId.slice(0, 8));
}, 10); }, 10);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@@ -1006,7 +996,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Copy on select if enabled // Copy on select if enabled
if (hasText && terminalSettings?.copyOnSelect) { if (hasText && terminalSettings?.copyOnSelect) {
navigator.clipboard.writeText(selection).catch(err => { navigator.clipboard.writeText(selection).catch(err => {
console.warn('Copy on select failed:', err); logger.warn('Copy on select failed:', err);
}); });
} }
}; };
@@ -1040,10 +1030,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
term.clear?.(); term.clear?.();
} catch (err) { } catch (err) {
console.warn("Failed to clear terminal before connect", err); logger.warn("Failed to clear terminal before connect", err);
} }
if (!window.netcatty?.startSSHSession) { if (!terminalBackend.backendAvailable()) {
setError("Native SSH bridge unavailable. Launch via Electron app."); setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln( term.writeln(
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]", "\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]",
@@ -1106,8 +1096,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]); ]);
// Subscribe to chain progress events from IPC // Subscribe to chain progress events from IPC
if (window.netcatty?.onChainProgress) { {
unsubscribeChainProgress = window.netcatty.onChainProgress( const unsub = terminalBackend.onChainProgress(
(hop, total, label, status) => { (hop, total, label, status) => {
setChainProgress({ setChainProgress({
currentHop: hop, currentHop: hop,
@@ -1123,6 +1113,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressValue(Math.min(95, hopProgress)); setProgressValue(Math.min(95, hopProgress));
}, },
); );
if (unsub) unsubscribeChainProgress = unsub;
} }
} }
@@ -1139,7 +1130,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
} }
const id = await window.netcatty.startSSHSession({ const id = await terminalBackend.startSSHSession({
sessionId, sessionId,
hostname: host.hostname, hostname: host.hostname,
username: effectiveUsername, username: effectiveUsername,
@@ -1165,7 +1156,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionRef.current = id; sessionRef.current = id;
disposeDataRef.current = window.netcatty.onSessionData(id, (chunk) => { disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
// Apply keyword highlighting before writing to terminal // Apply keyword highlighting before writing to terminal
term.write(highlightProcessorRef.current(chunk)); term.write(highlightProcessorRef.current(chunk));
if (!hasConnectedRef.current) { if (!hasConnectedRef.current) {
@@ -1177,22 +1168,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
// Send updated size to remote // Send updated size to remote
if (sessionRef.current && window.netcatty?.resizeSession) { if (sessionRef.current) {
window.netcatty.resizeSession( terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
sessionRef.current,
term.cols,
term.rows,
);
} }
} catch (err) { } catch (err) {
console.warn("Post-connect fit failed", err); logger.warn("Post-connect fit failed", err);
} }
} }
}, 100); }, 100);
} }
}); });
disposeExitRef.current = window.netcatty.onSessionExit(id, (evt) => { disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
updateStatus("disconnected"); updateStatus("disconnected");
setChainProgress(null); // Clear chain progress on disconnect setChainProgress(null); // Clear chain progress on disconnect
term.writeln( term.writeln(
@@ -1207,10 +1194,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasRunStartupCommandRef.current = true; hasRunStartupCommandRef.current = true;
setTimeout(() => { setTimeout(() => {
if (sessionRef.current) { if (sessionRef.current) {
window.netcatty?.writeToSession( terminalBackend.writeToSession(sessionRef.current, `${commandToRun}\r`);
sessionRef.current,
`${commandToRun}\r`,
);
// Track startup command execution in shell history // Track startup command execution in shell history
if (onCommandExecuted) { if (onCommandExecuted) {
onCommandExecuted(commandToRun, host.id, host.label, sessionId); onCommandExecuted(commandToRun, host.id, host.label, sessionId);
@@ -1263,11 +1247,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
term.clear?.(); term.clear?.();
} catch (err) { } catch (err) {
console.warn("Failed to clear terminal before connect", err); logger.warn("Failed to clear terminal before connect", err);
} }
const startTelnetSession = window.netcatty?.startTelnetSession; if (!terminalBackend.telnetAvailable()) {
if (!startTelnetSession) {
setError("Telnet bridge unavailable. Please run the desktop build."); setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln( term.writeln(
"\r\n[Telnet bridge unavailable. Please run the desktop build.]", "\r\n[Telnet bridge unavailable. Please run the desktop build.]",
@@ -1289,7 +1272,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
} }
const id = await startTelnetSession({ const id = await terminalBackend.startTelnetSession({
sessionId, sessionId,
hostname: host.hostname, hostname: host.hostname,
port: host.telnetPort || host.port || 23, port: host.telnetPort || host.port || 23,
@@ -1301,7 +1284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionRef.current = id; sessionRef.current = id;
disposeDataRef.current = window.netcatty?.onSessionData(id, (chunk) => { disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
// Apply keyword highlighting before writing to terminal // Apply keyword highlighting before writing to terminal
term.write(highlightProcessorRef.current(chunk)); term.write(highlightProcessorRef.current(chunk));
if (!hasConnectedRef.current) { if (!hasConnectedRef.current) {
@@ -1310,22 +1293,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (fitAddonRef.current) { if (fitAddonRef.current) {
try { try {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
if (sessionRef.current && window.netcatty?.resizeSession) { if (sessionRef.current) {
window.netcatty.resizeSession( terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
sessionRef.current,
term.cols,
term.rows,
);
} }
} catch (err) { } catch (err) {
console.warn("Post-connect fit failed", err); logger.warn("Post-connect fit failed", err);
} }
} }
}, 100); }, 100);
} }
}); });
disposeExitRef.current = window.netcatty?.onSessionExit(id, (evt) => { disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
updateStatus("disconnected"); updateStatus("disconnected");
term.writeln( term.writeln(
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, `\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
@@ -1344,11 +1323,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
term.clear?.(); term.clear?.();
} catch (err) { } catch (err) {
console.warn("Failed to clear terminal before connect", err); logger.warn("Failed to clear terminal before connect", err);
} }
const startMoshSession = window.netcatty?.startMoshSession; if (!terminalBackend.moshAvailable()) {
if (!startMoshSession) {
setError("Mosh bridge unavailable. Please run the desktop build."); setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln( term.writeln(
"\r\n[Mosh bridge unavailable. Please run the desktop build.]", "\r\n[Mosh bridge unavailable. Please run the desktop build.]",
@@ -1370,7 +1348,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
} }
const id = await startMoshSession({ const id = await terminalBackend.startMoshSession({
sessionId, sessionId,
hostname: host.hostname, hostname: host.hostname,
username: host.username || "root", username: host.username || "root",
@@ -1385,7 +1363,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionRef.current = id; sessionRef.current = id;
disposeDataRef.current = window.netcatty?.onSessionData(id, (chunk) => { disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
// Apply keyword highlighting before writing to terminal // Apply keyword highlighting before writing to terminal
term.write(highlightProcessorRef.current(chunk)); term.write(highlightProcessorRef.current(chunk));
if (!hasConnectedRef.current) { if (!hasConnectedRef.current) {
@@ -1394,22 +1372,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (fitAddonRef.current) { if (fitAddonRef.current) {
try { try {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
if (sessionRef.current && window.netcatty?.resizeSession) { if (sessionRef.current) {
window.netcatty.resizeSession( terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
sessionRef.current,
term.cols,
term.rows,
);
} }
} catch (err) { } catch (err) {
console.warn("Post-connect fit failed", err); logger.warn("Post-connect fit failed", err);
} }
} }
}, 100); }, 100);
} }
}); });
disposeExitRef.current = window.netcatty?.onSessionExit(id, (evt) => { disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
updateStatus("disconnected"); updateStatus("disconnected");
term.writeln( term.writeln(
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, `\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
@@ -1423,10 +1397,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasRunStartupCommandRef.current = true; hasRunStartupCommandRef.current = true;
setTimeout(() => { setTimeout(() => {
if (sessionRef.current) { if (sessionRef.current) {
window.netcatty?.writeToSession( terminalBackend.writeToSession(sessionRef.current, `${commandToRun}\r`);
sessionRef.current,
`${commandToRun}\r`,
);
if (onCommandExecuted) { if (onCommandExecuted) {
onCommandExecuted(commandToRun, host.id, host.label, sessionId); onCommandExecuted(commandToRun, host.id, host.label, sessionId);
} }
@@ -1445,11 +1416,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
term.clear?.(); term.clear?.();
} catch (err) { } catch (err) {
console.warn("Failed to clear terminal before connect", err); logger.warn("Failed to clear terminal before connect", err);
} }
const startLocalSession = window.netcatty?.startLocalSession; if (!terminalBackend.localAvailable()) {
if (!startLocalSession) {
setError("Local shell bridge unavailable. Please run the desktop build."); setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln( term.writeln(
"\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]", "\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]",
@@ -1459,7 +1429,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
try { try {
const id = await startLocalSession({ const id = await terminalBackend.startLocalSession({
sessionId, sessionId,
cols: term.cols, cols: term.cols,
rows: term.rows, rows: term.rows,
@@ -1468,7 +1438,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, },
}); });
sessionRef.current = id; sessionRef.current = id;
disposeDataRef.current = window.netcatty?.onSessionData(id, (chunk) => { disposeDataRef.current = terminalBackend.onSessionData(id, (chunk) => {
// Apply keyword highlighting before writing to terminal // Apply keyword highlighting before writing to terminal
term.write(highlightProcessorRef.current(chunk)); term.write(highlightProcessorRef.current(chunk));
if (!hasConnectedRef.current) { if (!hasConnectedRef.current) {
@@ -1479,21 +1449,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
fitAddonRef.current.fit(); fitAddonRef.current.fit();
// Send updated size to remote // Send updated size to remote
if (sessionRef.current && window.netcatty?.resizeSession) { if (sessionRef.current) {
window.netcatty.resizeSession( terminalBackend.resizeSession(sessionRef.current, term.cols, term.rows);
sessionRef.current,
term.cols,
term.rows,
);
} }
} catch (err) { } catch (err) {
console.warn("Post-connect fit failed", err); logger.warn("Post-connect fit failed", err);
} }
} }
}, 100); }, 100);
} }
}); });
disposeExitRef.current = window.netcatty?.onSessionExit(id, (evt) => { disposeExitRef.current = terminalBackend.onSessionExit(id, (evt) => {
updateStatus("disconnected"); updateStatus("disconnected");
term.writeln( term.writeln(
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`, `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
@@ -1509,8 +1475,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}; };
const handleSnippetClick = (cmd: string) => { const handleSnippetClick = (cmd: string) => {
if (sessionRef.current && window.netcatty?.writeToSession) { if (sessionRef.current) {
window.netcatty.writeToSession(sessionRef.current, `${cmd}\r`); terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
setIsScriptsOpen(false); setIsScriptsOpen(false);
termRef.current?.focus(); termRef.current?.focus();
return; return;
@@ -1631,7 +1597,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try { try {
termRef.current.clear?.(); termRef.current.clear?.();
} catch (err) { } catch (err) {
console.warn("Failed to clear terminal", err); logger.warn("Failed to clear terminal", err);
} }
startSSH(termRef.current); startSSH(termRef.current);
} }
@@ -1647,18 +1613,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
}; };
const handleContextPaste = async () => { const handleContextPaste = async () => {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
if (text && sessionRef.current && window.netcatty?.writeToSession) { if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, text);
window.netcatty.writeToSession(sessionRef.current, text); } catch (err) {
} logger.warn("Failed to paste from clipboard", err);
} catch (err) { }
console.warn("Failed to paste from clipboard", err); };
}
};
const handleContextSelectAll = () => { const handleContextSelectAll = () => {
const term = termRef.current; const term = termRef.current;

View File

@@ -387,9 +387,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusMode = activeWorkspace?.viewMode === 'focus'; const isFocusMode = activeWorkspace?.viewMode === 'focus';
const focusedSessionId = activeWorkspace?.focusedSessionId; const focusedSessionId = activeWorkspace?.focusedSessionId;
// Debug log for focus tracking
console.log('[TerminalLayer] focusedSessionId:', focusedSessionId?.slice(0, 8), 'isFocusMode:', isFocusMode);
// Track previous focusedSessionId to detect changes // Track previous focusedSessionId to detect changes
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined); const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
@@ -421,7 +418,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const textarea = targetPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null; const textarea = targetPane.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
if (textarea) { if (textarea) {
textarea.focus(); textarea.focus();
console.log('[TerminalLayer] Direct DOM focus on session:', focusedSessionId.slice(0, 8));
} }
} }
}; };

View File

@@ -1,6 +1,7 @@
import { Bell, Copy, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, User, X } from 'lucide-react'; import { Bell, Copy, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, User, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore'; import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { useWindowControls } from '../application/state/useWindowControls';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types'; import { TerminalSession, Workspace } from '../types';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -39,31 +40,32 @@ const sessionStatusDot = (status: TerminalSession['status']) => {
// Custom window controls for Windows/Linux (frameless window) // Custom window controls for Windows/Linux (frameless window)
const WindowControls: React.FC = memo(() => { const WindowControls: React.FC = memo(() => {
const { minimize, maximize, close, isMaximized: fetchIsMaximized } = useWindowControls();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => { useEffect(() => {
// Check initial maximized state // Check initial maximized state
window.netcatty?.windowIsMaximized?.().then(setIsMaximized); fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state // Listen for window resize to update maximized state
const handleResize = () => { const handleResize = () => {
window.netcatty?.windowIsMaximized?.().then(setIsMaximized); fetchIsMaximized().then(v => setIsMaximized(!!v));
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, [fetchIsMaximized]);
const handleMinimize = () => { const handleMinimize = () => {
window.netcatty?.windowMinimize?.(); minimize();
}; };
const handleMaximize = async () => { const handleMaximize = async () => {
const result = await window.netcatty?.windowMaximize?.(); const result = await maximize();
setIsMaximized(result ?? false); setIsMaximized(!!result);
}; };
const handleClose = () => { const handleClose = () => {
window.netcatty?.windowClose?.(); close();
}; };
return ( return (
@@ -118,6 +120,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onReorderTabs, onReorderTabs,
}) => { }) => {
// Subscribe to activeTabId from external store // Subscribe to activeTabId from external store
const { maximize } = useWindowControls();
const activeTabId = useActiveTabId(); const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault'; const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp'; const isSftpActive = activeTabId === 'sftp';
@@ -436,9 +439,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Only handle double-click on the drag region itself, not on buttons/tabs // Only handle double-click on the drag region itself, not on buttons/tabs
if ((e.target as HTMLElement).closest('.app-no-drag')) return; if ((e.target as HTMLElement).closest('.app-no-drag')) return;
if (!isMacClient) { if (!isMacClient) {
window.netcatty?.windowMaximize?.(); maximize();
} }
}, [isMacClient]); }, [isMacClient, maximize]);
return ( return (
<div <div

View File

@@ -1,9 +1,10 @@
/** /**
* Export Key Panel - Export SSH key to remote host * Export Key Panel - Export SSH key to remote host
*/ */
import { ChevronRight, Info } from 'lucide-react'; import { ChevronRight, Info } from 'lucide-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types'; import { Host, SSHKey } from '../../types';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -44,14 +45,15 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
exportHost, exportHost,
_setExportHost, // Host selection handled by onShowHostSelector callback _setExportHost, // Host selection handled by onShowHostSelector callback
onShowHostSelector, onShowHostSelector,
onSaveHost, onSaveHost,
onClose, onClose,
}) => { }) => {
const [exportLocation, setExportLocation] = useState('.ssh'); const { execCommand } = useKeychainBackend();
const [exportFilename, setExportFilename] = useState('authorized_keys'); const [exportLocation, setExportLocation] = useState('.ssh');
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false); const [exportFilename, setExportFilename] = useState('authorized_keys');
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT); const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
const [isExporting, setIsExporting] = useState(false);
const isMac = isMacOS(); const isMac = isMacOS();
@@ -80,14 +82,14 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
.replace(/\$2/g, exportFilename) .replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`); .replace(/\$3/g, `'${escapedPublicKey}'`);
const command = scriptWithVars; const command = scriptWithVars;
// Execute via SSH // Execute via SSH
const result = await window.netcatty?.execCommand({ const result = await execCommand({
hostname: exportHost.hostname, hostname: exportHost.hostname,
username: exportHost.username, username: exportHost.username,
port: exportHost.port || 22, port: exportHost.port || 22,
password: exportHost.password, password: exportHost.password,
privateKey: hostPrivateKey, privateKey: hostPrivateKey,
command, command,
timeout: 30000, timeout: 30000,

View File

@@ -4,6 +4,7 @@
import { BadgeCheck,Fingerprint,Key,Shield } from 'lucide-react'; import { BadgeCheck,Fingerprint,Key,Shield } from 'lucide-react';
import React from 'react'; import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType,SSHKey } from '../../types'; import { KeyType,SSHKey } from '../../types';
/** /**
@@ -109,7 +110,7 @@ export const createFido2Credential = async (label: string): Promise<{
rpId, rpId,
}; };
} catch (error) { } catch (error) {
console.error('FIDO2 credential creation failed:', error); logger.error('FIDO2 credential creation failed:', error);
throw error; throw error;
} }
}; };
@@ -200,7 +201,7 @@ export const createBiometricCredential = async (label: string): Promise<{
rpId, rpId,
}; };
} catch (error) { } catch (error) {
console.error('WebAuthn credential creation failed:', error); logger.error('WebAuthn credential creation failed:', error);
throw error; throw error;
} }
}; };
@@ -242,7 +243,7 @@ export const copyToClipboard = async (text: string): Promise<boolean> => {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true; return true;
} catch (err) { } catch (err) {
console.error('Failed to copy to clipboard:', err); logger.error('Failed to copy to clipboard:', err);
return false; return false;
} }
}; };

View File

@@ -188,12 +188,12 @@ export const collectSessionIds = (node: WorkspaceNode): string[] => {
/** /**
* Find a pane node by session ID in the workspace tree. * Find a pane node by session ID in the workspace tree.
*/ */
const findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceNode | null => { const _findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceNode | null => {
if (node.type === 'pane') { if (node.type === 'pane') {
return node.sessionId === sessionId ? node : null; return node.sessionId === sessionId ? node : null;
} }
for (const child of node.children) { for (const child of node.children) {
const found = findPaneBySessionId(child, sessionId); const found = _findPaneBySessionId(child, sessionId);
if (found) return found; if (found) return found;
} }
return null; return null;
@@ -203,12 +203,12 @@ const findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceN
* Get the path to a session in the workspace tree. * Get the path to a session in the workspace tree.
* Returns an array of indices representing the path from root to the pane. * Returns an array of indices representing the path from root to the pane.
*/ */
const getPathToSession = (node: WorkspaceNode, sessionId: string, path: number[] = []): number[] | null => { const _getPathToSession = (node: WorkspaceNode, sessionId: string, path: number[] = []): number[] | null => {
if (node.type === 'pane') { if (node.type === 'pane') {
return node.sessionId === sessionId ? path : null; return node.sessionId === sessionId ? path : null;
} }
for (let i = 0; i < node.children.length; i++) { for (let i = 0; i < node.children.length; i++) {
const result = getPathToSession(node.children[i], sessionId, [...path, i]); const result = _getPathToSession(node.children[i], sessionId, [...path, i]);
if (result) return result; if (result) return result;
} }
return null; return null;
@@ -286,17 +286,11 @@ export const getNextFocusSessionId = (
direction: FocusDirection direction: FocusDirection
): string | null => { ): string | null => {
const positions = collectPanePositions(root); const positions = collectPanePositions(root);
console.log('[getNextFocusSessionId] All positions:');
positions.forEach((p, i) => {
console.log(` [${i}] sessionId: ${p.sessionId.slice(0, 8)}..., x: ${p.x}, y: ${p.y}, w: ${p.width}, h: ${p.height}`);
});
const current = positions.find(p => p.sessionId === currentSessionId); const current = positions.find(p => p.sessionId === currentSessionId);
if (!current) { if (!current) {
console.log('[getNextFocusSessionId] Current session not found in positions');
return null; return null;
} }
console.log('[getNextFocusSessionId] Current pane:', current.sessionId.slice(0, 8), 'at x:', current.x, 'y:', current.y, 'w:', current.width, 'h:', current.height);
// Filter candidates based on direction // Filter candidates based on direction
let candidates: PanePosition[] = []; let candidates: PanePosition[] = [];
@@ -349,11 +343,6 @@ export const getNextFocusSessionId = (
break; break;
} }
console.log('[getNextFocusSessionId] Direction:', direction, 'Candidates count:', candidates.length, '(wraparound enabled)');
candidates.forEach((c, i) => {
console.log(` Candidate[${i}]: ${c.sessionId.slice(0, 8)}... at x: ${c.x}, y: ${c.y}`);
});
if (candidates.length === 0) return null; if (candidates.length === 0) return null;
// Calculate center point of current pane for scoring // Calculate center point of current pane for scoring
@@ -400,6 +389,5 @@ export const getNextFocusSessionId = (
} }
} }
console.log('[getNextFocusSessionId] Best candidate:', best?.sessionId?.slice(0, 8));
return best?.sessionId || null; return best?.sessionId || null;
}; };

View File

@@ -1,4 +1,4 @@
/** /**
* Local Filesystem Bridge - Handles local file operations * Local Filesystem Bridge - Handles local file operations
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* Port Forwarding Bridge - Handles SSH port forwarding tunnels * Port Forwarding Bridge - Handles SSH port forwarding tunnels
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* SFTP Bridge - Handles SFTP connections and file operations * SFTP Bridge - Handles SFTP connections and file operations
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* SSH Bridge - Handles SSH connections, sessions, and related operations * SSH Bridge - Handles SSH connections, sessions, and related operations
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* Terminal Bridge - Handles local shell and telnet/mosh sessions * Terminal Bridge - Handles local shell and telnet/mosh sessions
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* Transfer Bridge - Handles file transfers with progress and cancellation * Transfer Bridge - Handles file transfers with progress and cancellation
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
/** /**
* Window Manager - Handles Electron window creation and management * Window Manager - Handles Electron window creation and management
* Extracted from main.cjs for single responsibility * Extracted from main.cjs for single responsibility
*/ */

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge } = require("electron"); const { ipcRenderer, contextBridge } = require("electron");
const dataListeners = new Map(); const dataListeners = new Map();
const exitListeners = new Map(); const exitListeners = new Map();

View File

@@ -119,4 +119,53 @@ export default [
"no-case-declarations": "warn", "no-case-declarations": "warn",
}, },
}, },
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-restricted-properties": [
"error",
{
object: "window",
property: "netcatty",
message:
"Do not access window.netcatty directly; use netcattyBridge or an application/state backend hook.",
},
],
"no-restricted-globals": ["error", "localStorage", "sessionStorage"],
},
},
{
files: ["infrastructure/services/netcattyBridge.ts"],
rules: {
"no-restricted-properties": "off",
},
},
{
files: ["infrastructure/persistence/localStorageAdapter.ts"],
rules: {
"no-restricted-globals": "off",
},
},
{
files: ["components/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"../infrastructure/persistence/*",
"../infrastructure/services/*",
"../../infrastructure/persistence/*",
"../../infrastructure/services/*",
],
message:
"Components should not import infrastructure persistence/services; use application/state hooks instead.",
},
],
},
],
},
},
]; ];

View File

@@ -104,45 +104,13 @@
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
} }
</style> </style>
<script> <link rel="stylesheet" href="/index.css">
// Polyfill process for environment variables if missing </head>
if (typeof process === 'undefined') {
window.process = { env: {} };
}
</script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.1",
"react/jsx-runtime": "https://esm.sh/react@19.2.1/jsx-runtime",
"react-dom/client": "https://esm.sh/react-dom@19.2.1/client",
"react-dom": "https://esm.sh/react-dom@19.2.1",
"@google/genai": "https://esm.sh/@google/genai@1.31.0",
"lucide-react": "https://esm.sh/lucide-react@0.556.0?external=react,react-dom",
"clsx": "https://esm.sh/clsx@2.1.0",
"tailwind-merge": "https://esm.sh/tailwind-merge@2.2.0",
"xterm": "https://esm.sh/xterm@5.3.0",
"xterm-addon-fit": "https://esm.sh/xterm-addon-fit@0.8.0?external=xterm",
"@radix-ui/react-dialog": "https://esm.sh/@radix-ui/react-dialog@1.1.15?external=react,react-dom",
"@radix-ui/react-tabs": "https://esm.sh/@radix-ui/react-tabs@1.1.13?external=react,react-dom",
"@radix-ui/react-select": "https://esm.sh/@radix-ui/react-select@2.2.6?external=react,react-dom",
"@radix-ui/react-popover": "https://esm.sh/@radix-ui/react-popover@1.1.15?external=react,react-dom",
"@radix-ui/react-context-menu": "https://esm.sh/@radix-ui/react-context-menu@2.2.16?external=react,react-dom",
"@radix-ui/react-collapsible": "https://esm.sh/@radix-ui/react-collapsible@1.1.12?external=react,react-dom",
"@radix-ui/react-scroll-area": "https://esm.sh/@radix-ui/react-scroll-area@1.2.10?external=react,react-dom",
"@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot@1.2.4?external=react,react-dom",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
"react/": "https://aistudiocdn.com/react@^19.2.1/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-background text-foreground overflow-hidden antialiased h-screen w-screen"> <body class="bg-background text-foreground overflow-hidden antialiased h-screen w-screen">
<div id="root" class="h-full w-full"></div> <div id="root" class="h-full w-full"></div>
<script type="module" src="/index.tsx"></script> <script type="module" src="/index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -15,5 +15,6 @@ export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1'; export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_CUSTOM_CSS = 'netcatty_custom_css_v1'; export const STORAGE_KEY_CUSTOM_CSS = 'netcatty_custom_css_v1';
export const STORAGE_KEY_PORT_FORWARDING = 'netcatty_port_forwarding_v1'; export const STORAGE_KEY_PORT_FORWARDING = 'netcatty_port_forwarding_v1';
export const STORAGE_KEY_PF_PREFER_FORM_MODE = 'netcatty_pf_prefer_form_mode_v1';
export const STORAGE_KEY_KNOWN_HOSTS = 'netcatty_known_hosts_v1'; export const STORAGE_KEY_KNOWN_HOSTS = 'netcatty_known_hosts_v1';
export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1'; export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';

View File

@@ -20,6 +20,16 @@ export const localStorageAdapter = {
writeString(key: string, value: string) { writeString(key: string, value: string) {
localStorage.setItem(key, value); localStorage.setItem(key, value);
}, },
readBoolean(key: string): boolean | null {
const value = localStorage.getItem(key);
if (value === null) return null;
if (value === "true") return true;
if (value === "false") return false;
return null;
},
writeBoolean(key: string, value: boolean) {
localStorage.setItem(key, value ? "true" : "false");
},
readNumber(key: string): number | null { readNumber(key: string): number | null {
const value = localStorage.getItem(key); const value = localStorage.getItem(key);
if (!value) return null; if (!value) return null;

View File

@@ -0,0 +1,19 @@
export class BridgeUnavailableError extends Error {
constructor(message = "Netcatty bridge unavailable") {
super(message);
this.name = "BridgeUnavailableError";
}
}
export const netcattyBridge = {
get(): NetcattyBridge | undefined {
return window.netcatty;
},
require(): NetcattyBridge {
const bridge = window.netcatty;
if (!bridge) throw new BridgeUnavailableError();
return bridge;
},
};

View File

@@ -1,10 +1,12 @@
/** /**
* Port Forwarding Service * Port Forwarding Service
* Handles communication between the frontend and the Electron backend * Handles communication between the frontend and the Electron backend
* for establishing and managing SSH port forwarding tunnels. * for establishing and managing SSH port forwarding tunnels.
*/ */
import { Host,PortForwardingRule } from '../../domain/models'; import { Host,PortForwardingRule } from '../../domain/models';
import { logger } from '../../lib/logger';
import { netcattyBridge } from './netcattyBridge';
export interface PortForwardingConnection { export interface PortForwardingConnection {
ruleId: string; ruleId: string;
@@ -42,11 +44,11 @@ export const startPortForward = async (
keys: { id: string; privateKey: string }[], keys: { id: string; privateKey: string }[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
const bridge = window.netcatty; const bridge = netcattyBridge.get();
if (!bridge?.startPortForward) { if (!bridge?.startPortForward) {
// Fallback for browser/dev mode - simulate the connection // Fallback for browser/dev mode - simulate the connection
console.warn('[PortForwardingService] Backend not available, simulating connection...'); logger.warn('[PortForwardingService] Backend not available, simulating connection...');
return simulateConnection(rule, onStatusChange); return simulateConnection(rule, onStatusChange);
} }
@@ -122,7 +124,7 @@ export const stopPortForward = async (
ruleId: string, ruleId: string,
onStatusChange: (status: PortForwardingRule['status']) => void onStatusChange: (status: PortForwardingRule['status']) => void
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
const bridge = window.netcatty; const bridge = netcattyBridge.get();
const conn = activeConnections.get(ruleId); const conn = activeConnections.get(ruleId);
if (!conn) { if (!conn) {
@@ -132,7 +134,7 @@ export const stopPortForward = async (
if (!bridge?.stopPortForward) { if (!bridge?.stopPortForward) {
// Fallback for browser/dev mode // Fallback for browser/dev mode
console.warn('[PortForwardingService] Backend not available, simulating stop...'); logger.warn('[PortForwardingService] Backend not available, simulating stop...');
conn.unsubscribe?.(); conn.unsubscribe?.();
activeConnections.delete(ruleId); activeConnections.delete(ruleId);
onStatusChange('inactive'); onStatusChange('inactive');
@@ -169,25 +171,25 @@ export const getPortForwardStatus = async (
* Check if backend is available * Check if backend is available
*/ */
export const isBackendAvailable = (): boolean => { export const isBackendAvailable = (): boolean => {
return !!(window.netcatty?.startPortForward); return !!(netcattyBridge.get()?.startPortForward);
}; };
/** /**
* Stop all active tunnels (cleanup on unmount) * Stop all active tunnels (cleanup on unmount)
*/ */
export const stopAllPortForwards = async (): Promise<void> => { export const stopAllPortForwards = async (): Promise<void> => {
const bridge = window.netcatty; const bridge = netcattyBridge.get();
for (const [_ruleId, conn] of activeConnections) { for (const [_ruleId, conn] of activeConnections) {
try { try {
if (bridge?.stopPortForward) { if (bridge?.stopPortForward) {
await bridge.stopPortForward(conn.tunnelId); await bridge.stopPortForward(conn.tunnelId);
} }
conn.unsubscribe?.(); conn.unsubscribe?.();
} catch (err) { } catch (err) {
console.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err); logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
} }
} }
activeConnections.clear(); activeConnections.clear();
}; };

24
lib/logger.ts Normal file
View File

@@ -0,0 +1,24 @@
type LogArgs = unknown[];
const isDev =
typeof import.meta !== "undefined" &&
typeof import.meta.env !== "undefined" &&
!!import.meta.env.DEV;
export const logger = {
debug: (...args: LogArgs) => {
if (!isDev) return;
console.debug(...args);
},
info: (...args: LogArgs) => {
if (!isDev) return;
console.info(...args);
},
warn: (...args: LogArgs) => {
console.warn(...args);
},
error: (...args: LogArgs) => {
console.error(...args);
},
};

202
package-lock.json generated
View File

@@ -27,7 +27,7 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"ghostty-web": "^0.4.0", "ghostty-web": "^0.4.0",
"lucide-react": "0.560.0", "lucide-react": "0.560.0",
"node-pty": "^1.1.0-beta19", "node-pty": "1.1.0-beta19",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"ssh2-sftp-client": "^12.0.1", "ssh2-sftp-client": "^12.0.1",
@@ -440,7 +440,7 @@
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
@@ -457,7 +457,7 @@
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -474,7 +474,7 @@
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -491,7 +491,7 @@
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -525,7 +525,7 @@
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -542,7 +542,7 @@
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -559,7 +559,7 @@
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -576,7 +576,7 @@
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -593,7 +593,7 @@
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -610,7 +610,7 @@
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [ "cpu": [
"ia32" "ia32"
@@ -627,7 +627,7 @@
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [ "cpu": [
"loong64" "loong64"
@@ -644,7 +644,7 @@
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
@@ -661,7 +661,7 @@
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
@@ -678,7 +678,7 @@
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
@@ -695,7 +695,7 @@
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [ "cpu": [
"s390x" "s390x"
@@ -712,7 +712,7 @@
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -729,7 +729,7 @@
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -746,7 +746,7 @@
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -763,7 +763,7 @@
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -780,7 +780,7 @@
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -797,7 +797,7 @@
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -814,7 +814,7 @@
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -831,7 +831,7 @@
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -848,7 +848,7 @@
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
@@ -865,7 +865,7 @@
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2284,7 +2284,7 @@
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -2298,7 +2298,7 @@
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2326,7 +2326,7 @@
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2340,7 +2340,7 @@
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2354,7 +2354,7 @@
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2368,7 +2368,7 @@
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -2382,7 +2382,7 @@
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -2396,7 +2396,7 @@
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2410,7 +2410,7 @@
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2424,7 +2424,7 @@
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"cpu": [ "cpu": [
"loong64" "loong64"
@@ -2438,7 +2438,7 @@
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
@@ -2452,7 +2452,7 @@
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
@@ -2466,7 +2466,7 @@
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
@@ -2480,7 +2480,7 @@
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"cpu": [ "cpu": [
"s390x" "s390x"
@@ -2494,7 +2494,7 @@
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2508,7 +2508,7 @@
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2522,7 +2522,7 @@
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2536,7 +2536,7 @@
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2550,7 +2550,7 @@
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"cpu": [ "cpu": [
"ia32" "ia32"
@@ -2564,7 +2564,7 @@
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2578,7 +2578,7 @@
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3", "version": "4.53.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2665,7 +2665,7 @@
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2699,7 +2699,7 @@
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2716,7 +2716,7 @@
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2733,7 +2733,7 @@
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -2750,7 +2750,7 @@
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2767,7 +2767,7 @@
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2784,7 +2784,7 @@
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2801,7 +2801,7 @@
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -2818,7 +2818,7 @@
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
@@ -2846,9 +2846,69 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -2865,7 +2925,7 @@
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -5815,7 +5875,7 @@
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -5857,7 +5917,7 @@
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -5878,7 +5938,7 @@
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -5899,7 +5959,7 @@
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [ "cpu": [
"arm" "arm"
@@ -5920,7 +5980,7 @@
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -5941,7 +6001,7 @@
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -5962,7 +6022,7 @@
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -5983,7 +6043,7 @@
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -6004,7 +6064,7 @@
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
@@ -6025,7 +6085,7 @@
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [ "cpu": [
"x64" "x64"
@@ -6615,9 +6675,9 @@
} }
}, },
"node_modules/node-pty": { "node_modules/node-pty": {
"version": "1.1.0-beta9", "version": "1.1.0-beta19",
"resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0-beta9.tgz", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta19.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==", "integrity": "sha512-/p4Zu56EYDdXjjaLWzrIlFyrBnND11LQGP0/L6GEVGURfCNkAlHc3Twg/2I4NPxghimHXgvDlwp7Z2GtvDIh8A==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -34,7 +34,7 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"ghostty-web": "^0.4.0", "ghostty-web": "^0.4.0",
"lucide-react": "0.560.0", "lucide-react": "0.560.0",
"node-pty": "^1.1.0-beta19", "node-pty": "1.1.0-beta19",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"ssh2-sftp-client": "^12.0.1", "ssh2-sftp-client": "^12.0.1",

View File

@@ -1,15 +1,14 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import { defineConfig,loadEnv } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => { export default defineConfig(() => {
const env = loadEnv(mode, '.', '');
return { return {
base: "./", base: "./",
server: { server: {
port: 5173, port: 5173,
host: '0.0.0.0', host: '127.0.0.1',
headers: { headers: {
// Required for SharedArrayBuffer and WASM in some browsers // Required for SharedArrayBuffer and WASM in some browsers
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Opener-Policy': 'same-origin',
@@ -19,15 +18,22 @@ export default defineConfig(({ mode }) => {
build: { build: {
chunkSizeWarningLimit: 1500, chunkSizeWarningLimit: 1500,
target: 'esnext', // Required for top-level await in WASM modules target: 'esnext', // Required for top-level await in WASM modules
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('@xterm') || id.includes('xterm')) return 'xterm';
if (id.includes('@radix-ui')) return 'radix';
if (id.includes('react')) return 'react';
return 'vendor';
},
},
},
}, },
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
optimizeDeps: { optimizeDeps: {
exclude: ['ghostty-web'], // Don't pre-bundle ghostty-web to preserve WASM imports exclude: ['ghostty-web'], // Don't pre-bundle ghostty-web to preserve WASM imports
}, },
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),