mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
929d7dbe74
Refactors UI font handling to support dynamic font loading and improves selection in settings. Implements "Load More" pagination for connection logs, enhancing performance and user experience with large datasets. Adds lazy loading for the Connection Logs Manager. Addresses an issue in `usePortForwardingState` to prevent duplicate backend sync calls in React StrictMode. Improves IPC communication robustness by checking window destruction status. Refines the visual presentation of the snippets empty state.
313 lines
8.7 KiB
TypeScript
313 lines
8.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Host, PortForwardingRule } from "../../domain/models";
|
|
import {
|
|
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
|
STORAGE_KEY_PF_VIEW_MODE,
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
} from "../../infrastructure/config/storageKeys";
|
|
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
|
import {
|
|
clearReconnectTimer,
|
|
getActiveConnection,
|
|
getActiveRuleIds,
|
|
startPortForward,
|
|
stopPortForward,
|
|
syncWithBackend,
|
|
} from "../../infrastructure/services/portForwardingService";
|
|
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
|
|
|
export type { ViewMode };
|
|
|
|
export type SortMode = "az" | "za" | "newest" | "oldest";
|
|
|
|
export interface UsePortForwardingStateResult {
|
|
rules: PortForwardingRule[];
|
|
selectedRuleId: string | null;
|
|
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">,
|
|
) => PortForwardingRule;
|
|
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
|
deleteRule: (id: string) => void;
|
|
duplicateRule: (id: string) => void;
|
|
|
|
setRuleStatus: (
|
|
id: string,
|
|
status: PortForwardingRule["status"],
|
|
error?: string,
|
|
) => void;
|
|
|
|
startTunnel: (
|
|
rule: PortForwardingRule,
|
|
host: Host,
|
|
keys: { id: string; privateKey: string }[],
|
|
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
|
enableReconnect?: boolean,
|
|
) => Promise<{ success: boolean; error?: string }>;
|
|
stopTunnel: (
|
|
ruleId: string,
|
|
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
|
) => Promise<{ success: boolean; error?: string }>;
|
|
|
|
filteredRules: PortForwardingRule[];
|
|
selectedRule: PortForwardingRule | undefined;
|
|
}
|
|
|
|
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
|
const [rules, setRules] = useState<PortForwardingRule[]>([]);
|
|
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
|
const [viewMode, setViewMode] = useStoredViewMode(
|
|
STORAGE_KEY_PF_VIEW_MODE,
|
|
"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;
|
|
});
|
|
|
|
// Track if sync has been executed for this component instance
|
|
const syncExecutedRef = useRef(false);
|
|
|
|
const setPreferFormMode = useCallback((prefer: boolean) => {
|
|
setPreferFormModeState(prefer);
|
|
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
|
}, []);
|
|
|
|
// Load rules from storage on mount and sync with backend
|
|
useEffect(() => {
|
|
const loadAndSync = async () => {
|
|
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
|
|
if (!syncExecutedRef.current) {
|
|
syncExecutedRef.current = true;
|
|
await syncWithBackend();
|
|
}
|
|
|
|
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
|
STORAGE_KEY_PORT_FORWARDING,
|
|
);
|
|
if (saved && Array.isArray(saved)) {
|
|
// Sync status with active connections in the service layer
|
|
const _activeRuleIds = getActiveRuleIds();
|
|
const withSyncedStatus = saved.map((r) => {
|
|
const conn = getActiveConnection(r.id);
|
|
if (conn) {
|
|
// This rule has an active connection, preserve its status
|
|
return { ...r, status: conn.status, error: conn.error };
|
|
}
|
|
// No active connection, reset to inactive
|
|
return { ...r, status: "inactive" as const, error: undefined };
|
|
});
|
|
setRules(withSyncedStatus);
|
|
}
|
|
};
|
|
|
|
void loadAndSync();
|
|
}, []);
|
|
|
|
// Persist rules to storage whenever they change
|
|
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
|
|
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
|
}, []);
|
|
|
|
const addRule = useCallback(
|
|
(
|
|
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
|
|
): PortForwardingRule => {
|
|
const newRule: PortForwardingRule = {
|
|
...rule,
|
|
id: crypto.randomUUID(),
|
|
createdAt: Date.now(),
|
|
status: "inactive",
|
|
};
|
|
setRules((prev) => {
|
|
const updated = [...prev, newRule];
|
|
persistRules(updated);
|
|
return updated;
|
|
});
|
|
setSelectedRuleId(newRule.id);
|
|
return newRule;
|
|
},
|
|
[persistRules],
|
|
);
|
|
|
|
const updateRule = useCallback(
|
|
(id: string, updates: Partial<PortForwardingRule>) => {
|
|
setRules((prev) => {
|
|
const updated = prev.map((r) =>
|
|
r.id === id ? { ...r, ...updates } : r,
|
|
);
|
|
persistRules(updated);
|
|
return updated;
|
|
});
|
|
},
|
|
[persistRules],
|
|
);
|
|
|
|
const deleteRule = useCallback(
|
|
(id: string) => {
|
|
setRules((prev) => {
|
|
const updated = prev.filter((r) => r.id !== id);
|
|
persistRules(updated);
|
|
return updated;
|
|
});
|
|
if (selectedRuleId === id) {
|
|
setSelectedRuleId(null);
|
|
}
|
|
},
|
|
[selectedRuleId, persistRules],
|
|
);
|
|
|
|
const duplicateRule = useCallback(
|
|
(id: string) => {
|
|
const original = rules.find((r) => r.id === id);
|
|
if (!original) return;
|
|
|
|
const copy: PortForwardingRule = {
|
|
...original,
|
|
id: crypto.randomUUID(),
|
|
label: `${original.label} (Copy)`,
|
|
createdAt: Date.now(),
|
|
status: "inactive",
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
};
|
|
setRules((prev) => {
|
|
const updated = [...prev, copy];
|
|
persistRules(updated);
|
|
return updated;
|
|
});
|
|
setSelectedRuleId(copy.id);
|
|
},
|
|
[rules, persistRules],
|
|
);
|
|
|
|
const setRuleStatus = useCallback(
|
|
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
|
setRules((prev) => {
|
|
const updated = prev.map((r) => {
|
|
if (r.id !== id) return r;
|
|
return {
|
|
...r,
|
|
status,
|
|
error,
|
|
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
|
};
|
|
});
|
|
persistRules(updated);
|
|
return updated;
|
|
});
|
|
},
|
|
[persistRules],
|
|
);
|
|
|
|
const startTunnel = useCallback(
|
|
async (
|
|
rule: PortForwardingRule,
|
|
host: Host,
|
|
keys: { id: string; privateKey: string }[],
|
|
onStatusChange?: (
|
|
status: PortForwardingRule["status"],
|
|
error?: string,
|
|
) => void,
|
|
enableReconnect = false,
|
|
) => {
|
|
return startPortForward(rule, host, keys, (status, error) => {
|
|
setRuleStatus(rule.id, status, error);
|
|
onStatusChange?.(status, error ?? undefined);
|
|
}, enableReconnect);
|
|
},
|
|
[setRuleStatus],
|
|
);
|
|
|
|
const stopTunnel = useCallback(
|
|
async (
|
|
ruleId: string,
|
|
onStatusChange?: (status: PortForwardingRule["status"]) => void,
|
|
) => {
|
|
// Clear any pending reconnect timer when manually stopping
|
|
clearReconnectTimer(ruleId);
|
|
return stopPortForward(ruleId, (status) => {
|
|
setRuleStatus(ruleId, status);
|
|
onStatusChange?.(status);
|
|
});
|
|
},
|
|
[setRuleStatus],
|
|
);
|
|
|
|
// Filter and sort rules
|
|
const filteredRules = useMemo(() => {
|
|
let result = [...rules];
|
|
|
|
// Filter by search
|
|
if (search.trim()) {
|
|
const s = search.toLowerCase();
|
|
result = result.filter(
|
|
(r) =>
|
|
r.label.toLowerCase().includes(s) ||
|
|
r.type.toLowerCase().includes(s) ||
|
|
r.localPort.toString().includes(s) ||
|
|
r.remoteHost?.toLowerCase().includes(s) ||
|
|
r.remotePort?.toString().includes(s),
|
|
);
|
|
}
|
|
|
|
// Sort
|
|
switch (sortMode) {
|
|
case "az":
|
|
result.sort((a, b) => a.label.localeCompare(b.label));
|
|
break;
|
|
case "za":
|
|
result.sort((a, b) => b.label.localeCompare(a.label));
|
|
break;
|
|
case "newest":
|
|
result.sort((a, b) => b.createdAt - a.createdAt);
|
|
break;
|
|
case "oldest":
|
|
result.sort((a, b) => a.createdAt - b.createdAt);
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}, [rules, search, sortMode]);
|
|
|
|
const selectedRule = rules.find((r) => r.id === selectedRuleId);
|
|
|
|
return {
|
|
rules,
|
|
selectedRuleId,
|
|
viewMode,
|
|
sortMode,
|
|
search,
|
|
preferFormMode,
|
|
|
|
setSelectedRuleId,
|
|
setViewMode,
|
|
setSortMode,
|
|
setSearch,
|
|
setPreferFormMode,
|
|
|
|
addRule,
|
|
updateRule,
|
|
deleteRule,
|
|
duplicateRule,
|
|
|
|
setRuleStatus,
|
|
startTunnel,
|
|
stopTunnel,
|
|
|
|
filteredRules,
|
|
selectedRule,
|
|
};
|
|
};
|