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 { useSettingsState } from './application/state/useSettingsState';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { matchesKeyBinding } from './domain/models';
import ProtocolSelectDialog from './components/ProtocolSelectDialog';
import { QuickSwitcher } from './components/QuickSwitcher';
@@ -405,14 +406,15 @@ function App() {
setIsQuickSwitcherOpen(true);
}, []);
const { openSettingsWindow } = useWindowControls();
const handleOpenSettings = useCallback(() => {
// Try to open in a separate window, fallback to modal dialog
if (window.netcatty?.openSettingsWindow) {
window.netcatty.openSettingsWindow();
} else {
setIsSettingsOpen(true);
}
}, []);
void (async () => {
const opened = await openSettingsWindow();
if (!opened) setIsSettingsOpen(true);
})();
}, [openSettingsWindow]);
const handleEndSessionDrag = useCallback(() => {
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 { PortForwardingRule } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PORT_FORWARDING,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
getActiveConnection,
getActiveRuleIds,
startPortForward,
stopPortForward,
} from "../../infrastructure/services/portForwardingService";
export type ViewMode = "grid" | "list";
@@ -16,11 +21,13 @@ export interface UsePortForwardingStateResult {
viewMode: ViewMode;
sortMode: SortMode;
search: string;
preferFormMode: boolean;
setSelectedRuleId: (id: string | null) => void;
setViewMode: (mode: ViewMode) => void;
setSortMode: (mode: SortMode) => void;
setSearch: (query: string) => void;
setPreferFormMode: (prefer: boolean) => void;
addRule: (
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
@@ -35,6 +42,17 @@ export interface UsePortForwardingStateResult {
error?: string,
) => 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[];
selectedRule: PortForwardingRule | undefined;
}
@@ -45,6 +63,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [sortMode, setSortMode] = useState<SortMode>("newest");
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
useEffect(() => {
@@ -163,8 +189,39 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
[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
const filteredRules = useCallback(() => {
const filteredRules = useMemo(() => {
let result = [...rules];
// Filter by search
@@ -197,7 +254,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
}
return result;
}, [rules, search, sortMode])();
}, [rules, search, sortMode]);
const selectedRule = rules.find((r) => r.id === selectedRuleId);
@@ -207,11 +264,13 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
viewMode,
sortMode,
search,
preferFormMode,
setSelectedRuleId,
setViewMode,
setSortMode,
setSearch,
setPreferFormMode,
addRule,
updateRule,
@@ -219,6 +278,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
duplicateRule,
setRuleStatus,
startTunnel,
stopTunnel,
filteredRules,
selectedRule,

View File

@@ -36,10 +36,9 @@ export const useSessionState = () => {
username: 'local',
status: 'connecting',
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, []);
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
const newSession: TerminalSession = {
@@ -54,10 +53,9 @@ export const useSessionState = () => {
port: host.port,
moshEnabled: host.moshEnabled,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, []);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
@@ -141,10 +139,9 @@ export const useSessionState = () => {
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, [workspaces]);
return prevSessions.filter(s => s.id !== sessionId);
});
}, [workspaces, setActiveTabId]);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
@@ -161,10 +158,9 @@ export const useSessionState = () => {
}
}
return remainingWorkspaces;
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, []);
return remainingWorkspaces;
});
}, [setActiveTabId]);
const startWorkspaceRename = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
@@ -204,7 +200,7 @@ export const useSessionState = () => {
) => {
if (!hint || baseSessionId === joiningSessionId) return;
setSessions(prevSessions => {
setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
@@ -219,9 +215,8 @@ export const useSessionState = () => {
}
return s;
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, []);
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
@@ -230,7 +225,7 @@ export const useSessionState = () => {
) => {
if (!hint) return;
setSessions(prevSessions => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
@@ -246,9 +241,8 @@ export const useSessionState = () => {
setActiveTabId(workspaceId);
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[]) => {
setWorkspaces(prev => prev.map(ws => {
@@ -263,7 +257,7 @@ export const useSessionState = () => {
sessionId: string,
direction: SplitDirection
) => {
setSessions(prevSessions => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
@@ -328,9 +322,8 @@ export const useSessionState = () => {
}
return s;
}).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
const toggleWorkspaceViewMode = useCallback((workspaceId: string) => {
@@ -358,31 +351,23 @@ export const useSessionState = () => {
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
console.log('[moveFocusInWorkspace] Called with:', { workspaceId, direction });
const workspace = workspaces.find(w => w.id === workspaceId);
if (!workspace) {
console.log('[moveFocusInWorkspace] Workspace not found');
return false;
}
// Get current focused session, or first session if none focused
const sessionIds = collectSessionIds(workspace.root);
console.log('[moveFocusInWorkspace] Session IDs:', sessionIds);
const currentFocused = workspace.focusedSessionId || sessionIds[0];
if (!currentFocused) {
console.log('[moveFocusInWorkspace] No current focused session');
return false;
}
console.log('[moveFocusInWorkspace] Current focused:', currentFocused);
// Find the next session in the given direction
const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction);
console.log('[moveFocusInWorkspace] Next session:', nextSessionId);
if (!nextSessionId) {
console.log('[moveFocusInWorkspace] No next session found');
return false;
}
@@ -392,7 +377,6 @@ export const useSessionState = () => {
return { ...ws, focusedSessionId: nextSessionId };
}));
console.log('[moveFocusInWorkspace] Focus updated to:', nextSessionId);
return true;
}, [workspaces]);
@@ -428,11 +412,10 @@ export const useSessionState = () => {
startupCommand: snippet.command,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setActiveTabId is a stable store method reference
}, []);
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
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 {
STORAGE_KEY_COLOR,
@@ -15,6 +15,7 @@ STORAGE_KEY_CUSTOM_CSS,
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { TERMINAL_FONTS, DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_COLOR = '221.2 83.2% 53.3%';
const DEFAULT_THEME: 'light' | 'dark' = 'light';
@@ -40,7 +41,7 @@ const applyThemeTokens = (theme: 'light' | 'dark', primaryColor: string) => {
root.style.setProperty('--accent-foreground', accentForeground);
// Sync with native window title bar (Electron)
window.netcatty?.setTheme?.(theme);
netcattyBridge.get()?.setTheme?.(theme);
};
export const useSettingsState = () => {
@@ -109,14 +110,14 @@ export const useSettingsState = () => {
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
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 {
FileConflict,
Host,
@@ -9,6 +9,8 @@ import {
TransferStatus,
TransferTask,
} from "../../domain/models";
import { logger } from "../../lib/logger";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
// Helper functions
const formatFileSize = (bytes: number): string => {
@@ -265,14 +267,14 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const intervalsRef = progressIntervalsRef.current;
return () => {
// Clear all SFTP sessions
sessionsRef.forEach(async (sftpId) => {
try {
await window.netcatty?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
// Clear all SFTP sessions
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
// Clear all progress simulation intervals
intervalsRef.forEach((interval) => {
clearInterval(interval);
@@ -301,6 +303,47 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
[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
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
@@ -323,25 +366,25 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const oldSftpId = sftpSessionsRef.current.get(
currentPane.connection.id,
);
if (oldSftpId) {
try {
await window.netcatty?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
if (host === "local") {
// Local filesystem connection
// Try to get home directory from backend, fallback to platform-specific default
let homeDir = await window.netcatty?.getHomeDir?.();
if (!homeDir) {
// Detect platform and use appropriate default
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
// Local filesystem connection
// Try to get home directory from backend, fallback to platform-specific default
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) {
// Detect platform and use appropriate default
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const connection: SftpConnection = {
id: connectionId,
@@ -407,21 +450,21 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
files: prev.reconnecting ? prev.files : [], // Keep files if reconnecting
}));
try {
const credentials = getHostCredentials(host);
const sftpId = await window.netcatty?.openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
try {
const credentials = getHostCredentials(host);
const sftpId = await netcattyBridge.get()?.openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
// Try to get home directory, default to "/"
let startPath = "/";
const statSftp = window.netcatty?.statSftp;
if (statSftp) {
// Try to get home directory, default to "/"
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
@@ -438,26 +481,26 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username) {
try {
const homeFiles = await window.netcatty?.listSftp(
sftpId,
`/home/${credentials.username}`,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
} else {
if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
}
if (startPath === "/") {
try {
const rootFiles = await window.netcatty?.listSftp(
sftpId,
"/root",
);
if (rootFiles) startPath = "/root";
} catch {
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(
sftpId,
"/root",
);
if (rootFiles) startPath = "/root";
} catch {
// 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
@@ -561,16 +611,16 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await window.netcatty?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
setPane({
connection: null,
@@ -586,7 +636,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
);
// 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)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
@@ -1035,43 +1085,7 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
],
};
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
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
@@ -1305,18 +1325,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
await window.netcatty?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await window.netcatty?.mkdirSftp(sftpId, fullPath);
}
await refresh(side);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -1334,22 +1354,22 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection) return;
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) {
await window.netcatty?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await window.netcatty?.deleteSftp?.(sftpId, fullPath);
}
}
await refresh(side);
if (pane.connection.isLocal) {
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath);
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -1370,18 +1390,18 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, newName);
try {
if (pane.connection.isLocal) {
await window.netcatty?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await window.netcatty?.renameSftp?.(sftpId, oldPath, newPath);
}
await refresh(side);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -1428,17 +1448,17 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await window.netcatty?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await window.netcatty?.statSftp?.(
sourceSftpId,
fullPath,
);
if (stat) fileSize = stat.size;
}
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
);
if (stat) fileSize = stat.size;
}
} catch {
// If stat fails, we'll use estimate later
}
@@ -1488,35 +1508,35 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
if (sourcePane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) {
const stat = await window.netcatty?.statSftp?.(
sourceSftpId,
task.sourcePath,
);
if (stat) actualFileSize = stat.size;
}
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
);
if (stat) actualFileSize = stat.size;
}
} catch {
// Ignore stat errors, use estimate
}
}
// Estimate file size for progress simulation (use a reasonable default if unknown)
const estimatedSize =
const estimatedSize =
actualFileSize > 0
? actualFileSize
: task.isDirectory
? 1024 * 1024 // 1MB estimate for directories
: 256 * 1024; // 256KB default for files
// Check if streaming transfer is available (will provide real progress)
const hasStreamingTransfer = !!window.netcatty?.startStreamTransfer;
// Check if streaming transfer is available (will provide real progress)
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
@@ -1547,22 +1567,22 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
let sourceStat: { size: number; mtime: number } | null = null;
// Get source file stat for accurate size and mtime
try {
if (sourcePane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId && window.netcatty?.statSftp) {
const stat = await window.netcatty.statSftp(
sourceSftpId,
task.sourcePath,
);
if (stat) {
sourceStat = {
try {
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId && netcattyBridge.get()?.statSftp) {
const stat = await netcattyBridge.get()!.statSftp!(
sourceSftpId,
task.sourcePath,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
@@ -1573,24 +1593,24 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
}
// Get target file stat to check for conflict
try {
if (targetPane.connection?.isLocal) {
const stat = await window.netcatty?.statLocal?.(task.targetPath);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (targetSftpId && window.netcatty?.statSftp) {
const stat = await window.netcatty.statSftp(
targetSftpId,
task.targetPath,
);
if (stat) {
targetExists = true;
existingStat = {
try {
if (targetPane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (targetSftpId && netcattyBridge.get()?.statSftp) {
const stat = await netcattyBridge.get()!.statSftp!(
targetSftpId,
task.targetPath,
);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
@@ -1687,12 +1707,12 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
): Promise<void> => {
// Try to use streaming transfer if available
if (window.netcatty?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
): Promise<void> => {
// Try to use streaming transfer if available
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
@@ -1726,70 +1746,70 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
const onError = (error: string) => {
reject(new Error(error));
};
window.netcatty!.startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
// Fallback to legacy transfer (read all then write all)
let content: ArrayBuffer | string;
// Read from source
if (sourceIsLocal) {
content =
(await window.netcatty?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (window.netcatty?.readSftpBinary) {
content = await window.netcatty.readSftpBinary(
sourceSftpId,
task.sourcePath,
);
} else {
content =
(await window.netcatty?.readSftp(sourceSftpId, task.sourcePath)) || "";
}
} else {
throw new Error("No source connection");
}
// Read from source
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath)) || "";
}
} else {
throw new Error("No source connection");
}
// Write to target
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await window.netcatty?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await window.netcatty?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && window.netcatty?.writeSftpBinary) {
await window.netcatty.writeSftpBinary(
targetSftpId,
task.targetPath,
content,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await window.netcatty?.writeSftp(targetSftpId, task.targetPath, text);
}
} else {
throw new Error("No target connection");
}
};
// Write to target
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text);
}
} else {
throw new Error("No target connection");
}
};
// Transfer a directory
const transferDirectory = async (
@@ -1799,12 +1819,12 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
sourceIsLocal: boolean,
targetIsLocal: boolean,
) => {
// Create target directory
if (targetIsLocal) {
await window.netcatty?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) {
await window.netcatty?.mkdirSftp(targetSftpId, task.targetPath);
}
// Create target directory
if (targetIsLocal) {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath);
}
// List source directory
let files: SftpFileEntry[];
@@ -1873,14 +1893,14 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
// Remove from conflicts if present
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
// Cancel at backend level if streaming transfer is in progress
if (window.netcatty?.cancelTransfer) {
try {
await window.netcatty.cancelTransfer(transferId);
} catch (err) {
console.warn("Failed to cancel transfer at backend:", err);
}
}
// Cancel at backend level if streaming transfer is in progress
if (netcattyBridge.get()?.cancelTransfer) {
try {
await netcattyBridge.get()!.cancelTransfer!(transferId);
} catch (err) {
logger.warn("Failed to cancel transfer at backend:", err);
}
}
},
[stopProgressSimulation],
);
@@ -2028,25 +2048,25 @@ export const useSftpState = (hosts: Host[], keys: SSHKey[]) => {
) => {
const pane = side === "left" ? leftPane : rightPane;
if (!pane.connection || pane.connection.isLocal) {
console.warn("Cannot change permissions on local files");
logger.warn("Cannot change permissions on local files");
return;
}
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !window.netcatty?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
try {
await window.netcatty.chmodSftp(sftpId, filePath, mode);
await refresh(side);
} catch (err) {
try {
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode);
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
console.error("Failed to change permissions:", err);
logger.error("Failed to change permissions:", err);
}
},
[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,
ChevronDown,
ChevronRight,
@@ -15,8 +15,10 @@
UserPlus,
} from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
import { AsidePanel, AsidePanelContent } from "./ui/aside-panel";
import { Button } from "./ui/button";
@@ -77,6 +79,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
onSaveHost,
onCreateGroup,
}) => {
const { generateKeyPair, execCommand } = useKeychainBackend();
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -244,7 +247,7 @@ echo $3 >> "$FILE"`);
await navigator.clipboard.writeText(key.publicKey);
// Could add toast notification here
} 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;
// Use real key generation via Electron backend
if (window.netcatty?.generateKeyPair) {
const result = await window.netcatty.generateKeyPair({
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
});
if (!result.success || !result.privateKey || !result.publicKey) {
throw new Error(result.error || "Failed to generate key pair");
}
const result = await generateKeyPair({
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
});
if (!result) {
throw new Error(
"Key generation not available - please ensure the app is running in Electron",
);
}
if (!result.success || !result.privateKey || !result.publicKey) {
throw new Error(result.error || "Failed to generate key pair");
}
const newKey: SSHKey = {
id: crypto.randomUUID(),
@@ -355,17 +361,12 @@ echo $3 >> "$FILE"`);
onSave(newKey);
closePanel();
} else {
throw new Error(
"Key generation not available - please ensure the app is running in Electron",
);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate key");
} finally {
setIsGenerating(false);
}
}, [draftKey, onSave, closePanel]);
}, [draftKey, onSave, closePanel, generateKeyPair]);
// Handle biometric key generation (Windows Hello)
const handleGenerateBiometric = useCallback(async () => {
@@ -1235,7 +1236,7 @@ echo $3 >> "$FILE"`);
const command = scriptWithVars;
// Execute via SSH
const result = await window.netcatty?.execCommand({
const result = await execCommand({
hostname: exportHost.hostname,
username: exportHost.username,
port: exportHost.port || 22,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -387,9 +387,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusMode = activeWorkspace?.viewMode === 'focus';
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
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;
if (textarea) {
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 { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { useWindowControls } from '../application/state/useWindowControls';
import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types';
import { Button } from './ui/button';
@@ -39,31 +40,32 @@ const sessionStatusDot = (status: TerminalSession['status']) => {
// Custom window controls for Windows/Linux (frameless window)
const WindowControls: React.FC = memo(() => {
const { minimize, maximize, close, isMaximized: fetchIsMaximized } = useWindowControls();
const [isMaximized, setIsMaximized] = useState(false);
useEffect(() => {
// Check initial maximized state
window.netcatty?.windowIsMaximized?.().then(setIsMaximized);
fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state
const handleResize = () => {
window.netcatty?.windowIsMaximized?.().then(setIsMaximized);
fetchIsMaximized().then(v => setIsMaximized(!!v));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
}, [fetchIsMaximized]);
const handleMinimize = () => {
window.netcatty?.windowMinimize?.();
minimize();
};
const handleMaximize = async () => {
const result = await window.netcatty?.windowMaximize?.();
setIsMaximized(result ?? false);
const result = await maximize();
setIsMaximized(!!result);
};
const handleClose = () => {
window.netcatty?.windowClose?.();
close();
};
return (
@@ -118,6 +120,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onReorderTabs,
}) => {
// Subscribe to activeTabId from external store
const { maximize } = useWindowControls();
const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault';
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
if ((e.target as HTMLElement).closest('.app-no-drag')) return;
if (!isMacClient) {
window.netcatty?.windowMaximize?.();
maximize();
}
}, [isMacClient]);
}, [isMacClient, maximize]);
return (
<div

View File

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

View File

@@ -4,6 +4,7 @@
import { BadgeCheck,Fingerprint,Key,Shield } from 'lucide-react';
import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType,SSHKey } from '../../types';
/**
@@ -109,7 +110,7 @@ export const createFido2Credential = async (label: string): Promise<{
rpId,
};
} catch (error) {
console.error('FIDO2 credential creation failed:', error);
logger.error('FIDO2 credential creation failed:', error);
throw error;
}
};
@@ -200,7 +201,7 @@ export const createBiometricCredential = async (label: string): Promise<{
rpId,
};
} catch (error) {
console.error('WebAuthn credential creation failed:', error);
logger.error('WebAuthn credential creation failed:', error);
throw error;
}
};
@@ -242,7 +243,7 @@ export const copyToClipboard = async (text: string): Promise<boolean> => {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy to clipboard:', err);
logger.error('Failed to copy to clipboard:', err);
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.
*/
const findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceNode | null => {
const _findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceNode | null => {
if (node.type === 'pane') {
return node.sessionId === sessionId ? node : null;
}
for (const child of node.children) {
const found = findPaneBySessionId(child, sessionId);
const found = _findPaneBySessionId(child, sessionId);
if (found) return found;
}
return null;
@@ -203,12 +203,12 @@ const findPaneBySessionId = (node: WorkspaceNode, sessionId: string): WorkspaceN
* Get the path to a session in the workspace tree.
* 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') {
return node.sessionId === sessionId ? path : null;
}
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;
}
return null;
@@ -286,17 +286,11 @@ export const getNextFocusSessionId = (
direction: FocusDirection
): string | null => {
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);
if (!current) {
console.log('[getNextFocusSessionId] Current session not found in positions');
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
let candidates: PanePosition[] = [];
@@ -349,11 +343,6 @@ export const getNextFocusSessionId = (
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;
// 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;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
/**
/**
* Window Manager - Handles Electron window creation and management
* 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 exitListeners = new Map();

View File

@@ -119,4 +119,53 @@ export default [
"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,41 +104,9 @@
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<script>
// Polyfill process for environment variables if missing
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>
</style>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-background text-foreground overflow-hidden antialiased h-screen w-screen">
<div id="root" class="h-full w-full"></div>

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_CSS = 'netcatty_custom_css_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_SHELL_HISTORY = 'netcatty_shell_history_v1';

View File

@@ -20,6 +20,16 @@ export const localStorageAdapter = {
writeString(key: string, value: string) {
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 {
const value = localStorage.getItem(key);
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
* Handles communication between the frontend and the Electron backend
* for establishing and managing SSH port forwarding tunnels.
*/
import { Host,PortForwardingRule } from '../../domain/models';
import { logger } from '../../lib/logger';
import { netcattyBridge } from './netcattyBridge';
export interface PortForwardingConnection {
ruleId: string;
@@ -42,11 +44,11 @@ export const startPortForward = async (
keys: { id: string; privateKey: string }[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
): Promise<{ success: boolean; error?: string }> => {
const bridge = window.netcatty;
const bridge = netcattyBridge.get();
if (!bridge?.startPortForward) {
// 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);
}
@@ -122,7 +124,7 @@ export const stopPortForward = async (
ruleId: string,
onStatusChange: (status: PortForwardingRule['status']) => void
): Promise<{ success: boolean; error?: string }> => {
const bridge = window.netcatty;
const bridge = netcattyBridge.get();
const conn = activeConnections.get(ruleId);
if (!conn) {
@@ -132,7 +134,7 @@ export const stopPortForward = async (
if (!bridge?.stopPortForward) {
// Fallback for browser/dev mode
console.warn('[PortForwardingService] Backend not available, simulating stop...');
logger.warn('[PortForwardingService] Backend not available, simulating stop...');
conn.unsubscribe?.();
activeConnections.delete(ruleId);
onStatusChange('inactive');
@@ -169,25 +171,25 @@ export const getPortForwardStatus = async (
* Check if backend is available
*/
export const isBackendAvailable = (): boolean => {
return !!(window.netcatty?.startPortForward);
return !!(netcattyBridge.get()?.startPortForward);
};
/**
* Stop all active tunnels (cleanup on unmount)
*/
export const stopAllPortForwards = async (): Promise<void> => {
const bridge = window.netcatty;
const bridge = netcattyBridge.get();
for (const [_ruleId, conn] of activeConnections) {
try {
if (bridge?.stopPortForward) {
await bridge.stopPortForward(conn.tunnelId);
}
conn.unsubscribe?.();
} catch (err) {
console.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
}
}
try {
if (bridge?.stopPortForward) {
await bridge.stopPortForward(conn.tunnelId);
}
conn.unsubscribe?.();
} catch (err) {
logger.warn(`[PortForwardingService] Failed to stop tunnel ${conn.tunnelId}:`, err);
}
}
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",
"ghostty-web": "^0.4.0",
"lucide-react": "0.560.0",
"node-pty": "^1.1.0-beta19",
"node-pty": "1.1.0-beta19",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"ssh2-sftp-client": "^12.0.1",
@@ -440,7 +440,7 @@
},
"node_modules/@esbuild/aix-ppc64": {
"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==",
"cpu": [
"ppc64"
@@ -457,7 +457,7 @@
},
"node_modules/@esbuild/android-arm": {
"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==",
"cpu": [
"arm"
@@ -474,7 +474,7 @@
},
"node_modules/@esbuild/android-arm64": {
"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==",
"cpu": [
"arm64"
@@ -491,7 +491,7 @@
},
"node_modules/@esbuild/android-x64": {
"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==",
"cpu": [
"x64"
@@ -525,7 +525,7 @@
},
"node_modules/@esbuild/darwin-x64": {
"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==",
"cpu": [
"x64"
@@ -542,7 +542,7 @@
},
"node_modules/@esbuild/freebsd-arm64": {
"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==",
"cpu": [
"arm64"
@@ -559,7 +559,7 @@
},
"node_modules/@esbuild/freebsd-x64": {
"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==",
"cpu": [
"x64"
@@ -576,7 +576,7 @@
},
"node_modules/@esbuild/linux-arm": {
"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==",
"cpu": [
"arm"
@@ -593,7 +593,7 @@
},
"node_modules/@esbuild/linux-arm64": {
"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==",
"cpu": [
"arm64"
@@ -610,7 +610,7 @@
},
"node_modules/@esbuild/linux-ia32": {
"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==",
"cpu": [
"ia32"
@@ -627,7 +627,7 @@
},
"node_modules/@esbuild/linux-loong64": {
"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==",
"cpu": [
"loong64"
@@ -644,7 +644,7 @@
},
"node_modules/@esbuild/linux-mips64el": {
"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==",
"cpu": [
"mips64el"
@@ -661,7 +661,7 @@
},
"node_modules/@esbuild/linux-ppc64": {
"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==",
"cpu": [
"ppc64"
@@ -678,7 +678,7 @@
},
"node_modules/@esbuild/linux-riscv64": {
"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==",
"cpu": [
"riscv64"
@@ -695,7 +695,7 @@
},
"node_modules/@esbuild/linux-s390x": {
"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==",
"cpu": [
"s390x"
@@ -712,7 +712,7 @@
},
"node_modules/@esbuild/linux-x64": {
"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==",
"cpu": [
"x64"
@@ -729,7 +729,7 @@
},
"node_modules/@esbuild/netbsd-arm64": {
"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==",
"cpu": [
"arm64"
@@ -746,7 +746,7 @@
},
"node_modules/@esbuild/netbsd-x64": {
"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==",
"cpu": [
"x64"
@@ -763,7 +763,7 @@
},
"node_modules/@esbuild/openbsd-arm64": {
"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==",
"cpu": [
"arm64"
@@ -780,7 +780,7 @@
},
"node_modules/@esbuild/openbsd-x64": {
"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==",
"cpu": [
"x64"
@@ -797,7 +797,7 @@
},
"node_modules/@esbuild/openharmony-arm64": {
"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==",
"cpu": [
"arm64"
@@ -814,7 +814,7 @@
},
"node_modules/@esbuild/sunos-x64": {
"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==",
"cpu": [
"x64"
@@ -831,7 +831,7 @@
},
"node_modules/@esbuild/win32-arm64": {
"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==",
"cpu": [
"arm64"
@@ -848,7 +848,7 @@
},
"node_modules/@esbuild/win32-ia32": {
"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==",
"cpu": [
"ia32"
@@ -865,7 +865,7 @@
},
"node_modules/@esbuild/win32-x64": {
"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==",
"cpu": [
"x64"
@@ -2284,7 +2284,7 @@
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"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==",
"cpu": [
"arm"
@@ -2298,7 +2298,7 @@
},
"node_modules/@rollup/rollup-android-arm64": {
"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==",
"cpu": [
"arm64"
@@ -2326,7 +2326,7 @@
},
"node_modules/@rollup/rollup-darwin-x64": {
"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==",
"cpu": [
"x64"
@@ -2340,7 +2340,7 @@
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"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==",
"cpu": [
"arm64"
@@ -2354,7 +2354,7 @@
},
"node_modules/@rollup/rollup-freebsd-x64": {
"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==",
"cpu": [
"x64"
@@ -2368,7 +2368,7 @@
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"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==",
"cpu": [
"arm"
@@ -2382,7 +2382,7 @@
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"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==",
"cpu": [
"arm"
@@ -2396,7 +2396,7 @@
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"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==",
"cpu": [
"arm64"
@@ -2410,7 +2410,7 @@
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"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==",
"cpu": [
"arm64"
@@ -2424,7 +2424,7 @@
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"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==",
"cpu": [
"loong64"
@@ -2438,7 +2438,7 @@
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"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==",
"cpu": [
"ppc64"
@@ -2452,7 +2452,7 @@
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"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==",
"cpu": [
"riscv64"
@@ -2466,7 +2466,7 @@
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"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==",
"cpu": [
"riscv64"
@@ -2480,7 +2480,7 @@
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"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==",
"cpu": [
"s390x"
@@ -2494,7 +2494,7 @@
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"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==",
"cpu": [
"x64"
@@ -2508,7 +2508,7 @@
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"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==",
"cpu": [
"x64"
@@ -2522,7 +2522,7 @@
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"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==",
"cpu": [
"arm64"
@@ -2536,7 +2536,7 @@
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"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==",
"cpu": [
"arm64"
@@ -2550,7 +2550,7 @@
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"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==",
"cpu": [
"ia32"
@@ -2564,7 +2564,7 @@
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"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==",
"cpu": [
"x64"
@@ -2578,7 +2578,7 @@
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"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==",
"cpu": [
"x64"
@@ -2665,7 +2665,7 @@
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"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==",
"cpu": [
"arm64"
@@ -2699,7 +2699,7 @@
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"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==",
"cpu": [
"x64"
@@ -2716,7 +2716,7 @@
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"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==",
"cpu": [
"x64"
@@ -2733,7 +2733,7 @@
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"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==",
"cpu": [
"arm"
@@ -2750,7 +2750,7 @@
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"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==",
"cpu": [
"arm64"
@@ -2767,7 +2767,7 @@
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"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==",
"cpu": [
"arm64"
@@ -2784,7 +2784,7 @@
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"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==",
"cpu": [
"x64"
@@ -2801,7 +2801,7 @@
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"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==",
"cpu": [
"x64"
@@ -2818,7 +2818,7 @@
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"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==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
@@ -2846,9 +2846,69 @@
"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": {
"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==",
"cpu": [
"arm64"
@@ -2865,7 +2925,7 @@
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"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==",
"cpu": [
"x64"
@@ -5815,7 +5875,7 @@
},
"node_modules/lightningcss-android-arm64": {
"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==",
"cpu": [
"arm64"
@@ -5857,7 +5917,7 @@
},
"node_modules/lightningcss-darwin-x64": {
"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==",
"cpu": [
"x64"
@@ -5878,7 +5938,7 @@
},
"node_modules/lightningcss-freebsd-x64": {
"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==",
"cpu": [
"x64"
@@ -5899,7 +5959,7 @@
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"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==",
"cpu": [
"arm"
@@ -5920,7 +5980,7 @@
},
"node_modules/lightningcss-linux-arm64-gnu": {
"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==",
"cpu": [
"arm64"
@@ -5941,7 +6001,7 @@
},
"node_modules/lightningcss-linux-arm64-musl": {
"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==",
"cpu": [
"arm64"
@@ -5962,7 +6022,7 @@
},
"node_modules/lightningcss-linux-x64-gnu": {
"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==",
"cpu": [
"x64"
@@ -5983,7 +6043,7 @@
},
"node_modules/lightningcss-linux-x64-musl": {
"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==",
"cpu": [
"x64"
@@ -6004,7 +6064,7 @@
},
"node_modules/lightningcss-win32-arm64-msvc": {
"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==",
"cpu": [
"arm64"
@@ -6025,7 +6085,7 @@
},
"node_modules/lightningcss-win32-x64-msvc": {
"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==",
"cpu": [
"x64"
@@ -6615,9 +6675,9 @@
}
},
"node_modules/node-pty": {
"version": "1.1.0-beta9",
"resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0-beta9.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==",
"version": "1.1.0-beta19",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta19.tgz",
"integrity": "sha512-/p4Zu56EYDdXjjaLWzrIlFyrBnND11LQGP0/L6GEVGURfCNkAlHc3Twg/2I4NPxghimHXgvDlwp7Z2GtvDIh8A==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View File

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

View File

@@ -1,15 +1,14 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig,loadEnv } from 'vite';
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
export default defineConfig(() => {
return {
base: "./",
server: {
port: 5173,
host: '0.0.0.0',
host: '127.0.0.1',
headers: {
// Required for SharedArrayBuffer and WASM in some browsers
'Cross-Origin-Opener-Policy': 'same-origin',
@@ -19,15 +18,22 @@ export default defineConfig(({ mode }) => {
build: {
chunkSizeWarningLimit: 1500,
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()],
optimizeDeps: {
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: {
alias: {
'@': path.resolve(__dirname, '.'),