Clean up dead code and duplicated helpers (#1001)
This commit is contained in:
24
App.tsx
24
App.tsx
@@ -177,12 +177,22 @@ const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMount) return;
|
||||
// Warm up the terminal layer shortly after first paint to reduce latency when opening a session.
|
||||
const id = window.setTimeout(() => setShouldMount(true), 1200);
|
||||
type IdleWindow = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (id: number) => void;
|
||||
};
|
||||
const idleWindow = window as IdleWindow;
|
||||
if (typeof idleWindow.requestIdleCallback === "function") {
|
||||
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
|
||||
return () => idleWindow.cancelIdleCallback?.(id);
|
||||
}
|
||||
const id = window.setTimeout(() => setShouldMount(true), 5000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
if (!shouldMount) return null;
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1716,6 +1726,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
updateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -2012,7 +2026,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
sessionCount={sessions.length}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
terminalThemeId={terminalThemeId}
|
||||
@@ -2100,7 +2114,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
onUpdateHostDistro={updateHostDistro}
|
||||
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
|
||||
onUpdateHost={handleUpdateHostFromTerminal}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
|
||||
@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useEditorDirty = (id: EditorTabId): boolean => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useAnyEditorDirty = (): boolean => {
|
||||
const getSnapshot = useCallback(
|
||||
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
|
||||
[],
|
||||
);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
23
application/state/sftp/bookmarkHelpers.ts
Normal file
23
application/state/sftp/bookmarkHelpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
|
||||
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
|
||||
|
||||
export function getSftpBookmarkLabel(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
|
||||
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
|
||||
}
|
||||
|
||||
export function createSftpBookmark(
|
||||
path: string,
|
||||
options: { global?: boolean; idPrefix?: string } = {},
|
||||
): SftpBookmark {
|
||||
const global = options.global === true;
|
||||
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
|
||||
return {
|
||||
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label: getSftpBookmarkLabel(path),
|
||||
...(global ? { global: true } : {}),
|
||||
};
|
||||
}
|
||||
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
export function subscribeGlobalSftpBookmarks(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getGlobalSftpBookmarksSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function rehydrateGlobalSftpBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function setGlobalSftpBookmarks(
|
||||
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
|
||||
) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const listener of listeners) listener();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
|
||||
rehydrateGlobalSftpBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '..
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasMeaningfulCloudSyncData,
|
||||
} from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
@@ -156,21 +156,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
@@ -179,7 +164,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
prevTab: () => void;
|
||||
closeTab: () => void;
|
||||
newTab: () => void;
|
||||
|
||||
// Navigation
|
||||
openHosts: () => void;
|
||||
openSftp: () => void;
|
||||
quickSwitch: () => void;
|
||||
newWorkspace: () => void;
|
||||
commandPalette: () => void;
|
||||
portForwarding: () => void;
|
||||
snippets: () => void;
|
||||
|
||||
// Terminal actions (handled per-terminal)
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
selectAll: () => void;
|
||||
clearBuffer: () => void;
|
||||
searchTerminal: () => void;
|
||||
|
||||
// Workspace/split actions
|
||||
splitHorizontal: () => void;
|
||||
splitVertical: () => void;
|
||||
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
||||
|
||||
// App features
|
||||
broadcast: () => void;
|
||||
openLocal: () => void;
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
// Check if keyboard event matches our app-level shortcuts
|
||||
// Returns the matched binding action or null
|
||||
export const checkAppShortcut = (
|
||||
@@ -87,163 +51,3 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
'searchTerminal',
|
||||
]);
|
||||
};
|
||||
|
||||
interface UseGlobalHotkeysOptions {
|
||||
hotkeyScheme: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings: KeyBinding[];
|
||||
actions: Partial<HotkeyActions>;
|
||||
orderedTabs: string[];
|
||||
sessions: { id: string }[];
|
||||
workspaces: { id: string }[];
|
||||
isSettingsOpen?: boolean;
|
||||
}
|
||||
|
||||
export const useGlobalHotkeys = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
actions,
|
||||
orderedTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
isSettingsOpen = false,
|
||||
}: UseGlobalHotkeysOptions) => {
|
||||
const actionsRef = useRef(actions);
|
||||
actionsRef.current = actions;
|
||||
|
||||
const orderedTabsRef = useRef(orderedTabs);
|
||||
orderedTabsRef.current = orderedTabs;
|
||||
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (hotkeyScheme === 'disabled') return;
|
||||
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const appLevelActions = getAppLevelActions();
|
||||
|
||||
// Check if this is an app-level shortcut
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action, binding: _binding } = matched;
|
||||
|
||||
// Only handle app-level actions here
|
||||
// Terminal-level actions are handled by the terminal itself
|
||||
if (!appLevelActions.has(action)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentActions = actionsRef.current;
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
currentActions.switchToTab?.(num);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'nextTab':
|
||||
currentActions.nextTab?.();
|
||||
break;
|
||||
case 'prevTab':
|
||||
currentActions.prevTab?.();
|
||||
break;
|
||||
case 'closeTab':
|
||||
currentActions.closeTab?.();
|
||||
break;
|
||||
case 'newTab':
|
||||
currentActions.newTab?.();
|
||||
break;
|
||||
case 'openHosts':
|
||||
currentActions.openHosts?.();
|
||||
break;
|
||||
case 'openSftp':
|
||||
currentActions.openSftp?.();
|
||||
break;
|
||||
case 'openLocal':
|
||||
currentActions.openLocal?.();
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
currentActions.quickSwitch?.();
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
currentActions.newWorkspace?.();
|
||||
break;
|
||||
case 'commandPalette':
|
||||
currentActions.commandPalette?.();
|
||||
break;
|
||||
case 'portForwarding':
|
||||
currentActions.portForwarding?.();
|
||||
break;
|
||||
case 'snippets':
|
||||
currentActions.snippets?.();
|
||||
break;
|
||||
case 'splitHorizontal':
|
||||
currentActions.splitHorizontal?.();
|
||||
break;
|
||||
case 'splitVertical':
|
||||
currentActions.splitVertical?.();
|
||||
break;
|
||||
case 'moveFocus': {
|
||||
// Determine direction from arrow key
|
||||
const key = e.key;
|
||||
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
|
||||
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
|
||||
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
|
||||
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
|
||||
break;
|
||||
}
|
||||
case 'broadcast':
|
||||
currentActions.broadcast?.();
|
||||
break;
|
||||
case 'openSettings':
|
||||
currentActions.openSettings?.();
|
||||
break;
|
||||
}
|
||||
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before xterm
|
||||
window.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
}, [handleGlobalKeyDown]);
|
||||
};
|
||||
|
||||
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
|
||||
// Returns false to let xterm handle the key, true to prevent xterm from handling
|
||||
export const createXtermKeyHandler = (
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean,
|
||||
onTerminalAction?: (action: string, e: KeyboardEvent) => void
|
||||
) => {
|
||||
const appLevelActions = getAppLevelActions();
|
||||
const terminalActions = getTerminalPassthroughActions();
|
||||
|
||||
return (e: KeyboardEvent): boolean => {
|
||||
const matched = checkAppShortcut(e, keyBindings, isMac);
|
||||
if (!matched) return true; // Let xterm handle it
|
||||
|
||||
const { action } = matched;
|
||||
|
||||
// App-level actions: prevent xterm from handling, let global handler take over
|
||||
if (appLevelActions.has(action)) {
|
||||
return false; // Don't let xterm handle, will bubble to global handler
|
||||
}
|
||||
|
||||
// Terminal-level actions: handle here and prevent default
|
||||
if (terminalActions.has(action)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onTerminalAction?.(action, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Let xterm handle other keys
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,12 @@ import type {
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -26,7 +31,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,19 +100,7 @@ export interface SyncableVaultData {
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
@@ -118,24 +112,39 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
* Local-only trust records are intentionally ignored.
|
||||
*/
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
if (!rules) return rules;
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEffectivePortForwardingRulesForSync(
|
||||
rules: PortForwardingRule[] | undefined,
|
||||
): PortForwardingRule[] | undefined {
|
||||
let effectiveRules = rules;
|
||||
if (!effectiveRules || effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
effectiveRules = stored;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePortForwardingRulesForSync(effectiveRules);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
@@ -550,7 +559,7 @@ export function buildSyncPayload(
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -611,7 +620,7 @@ function applyPayload(
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { useUpdateCheck } from "../application/state/useUpdateCheck";
|
||||
import { useAIState } from "../application/state/useAIState";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
|
||||
import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
@@ -133,13 +134,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
|
||||
// Strip transient runtime fields before passing to sync
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Circle, Columns2, FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, Plus, Search, Server, X, Zap } from 'lucide-react';
|
||||
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { Suspense, createContext, lazy, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import {
|
||||
@@ -48,7 +48,6 @@ import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
@@ -62,6 +61,10 @@ import { terminalLayerAreEqual } from './terminalLayerMemo';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
const LazyAIChatSidePanel = lazy(() =>
|
||||
import('./AIChatSidePanel').then((m) => ({ default: m.AIChatSidePanel })),
|
||||
);
|
||||
|
||||
type WorkspaceRect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type SplitHint = {
|
||||
@@ -334,48 +337,52 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
key={tabId}
|
||||
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
|
||||
>
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
draftsByScope={aiState.draftsByScope}
|
||||
panelViewByScope={aiState.panelViewByScope}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
ensureDraftForScope={aiState.ensureDraftForScope}
|
||||
updateDraft={aiState.updateDraft}
|
||||
showDraftView={aiState.showDraftView}
|
||||
showSessionView={aiState.showSessionView}
|
||||
clearDraftForScope={aiState.clearDraftForScope}
|
||||
addDraftFiles={aiState.addDraftFiles}
|
||||
removeDraftFile={aiState.removeDraftFile}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={context.scopeType}
|
||||
scopeTargetId={context.scopeTargetId}
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
terminalSessions={context.terminalSessions}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
{isVisible && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyAIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
draftsByScope={aiState.draftsByScope}
|
||||
panelViewByScope={aiState.panelViewByScope}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
ensureDraftForScope={aiState.ensureDraftForScope}
|
||||
updateDraft={aiState.updateDraft}
|
||||
showDraftView={aiState.showDraftView}
|
||||
showSessionView={aiState.showSessionView}
|
||||
clearDraftForScope={aiState.clearDraftForScope}
|
||||
addDraftFiles={aiState.addDraftFiles}
|
||||
removeDraftFile={aiState.removeDraftFile}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={context.scopeType}
|
||||
scopeTargetId={context.scopeTargetId}
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
terminalSessions={context.terminalSessions}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -73,7 +73,7 @@ interface TextEditorModalProps {
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
|
||||
// Memoized theme item component
|
||||
export const ThemeItem = memo(({
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
|
||||
@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficDiagram;
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -135,7 +134,7 @@ interface VaultViewProps {
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
sessionCount: number;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
terminalThemeId: string;
|
||||
@@ -187,7 +186,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
sessionCount,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
terminalThemeId,
|
||||
@@ -2511,7 +2510,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
{t("vault.hosts.header.live", { count: sessionCount })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3291,7 +3290,7 @@ export const vaultViewAreEqual = (
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.shellHistory === next.shellHistory &&
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.sessionCount === next.sessionCount &&
|
||||
prev.managedSources === next.managedSources &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.terminalThemeId === next.terminalThemeId &&
|
||||
|
||||
@@ -47,20 +47,6 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const safeCode = createSafeCodeHighlighter(code);
|
||||
const streamdownPlugins = { cjk, code: safeCode };
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -145,37 +144,6 @@ export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps
|
||||
);
|
||||
PromptInputTools.displayName = 'PromptInputTools';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputButton (toolbar button with optional tooltip)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
|
||||
tooltip?: ReactNode;
|
||||
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
|
||||
const button = <InputGroupButton ref={ref} {...props} />;
|
||||
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
PromptInputButton.displayName = 'PromptInputButton';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSubmit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
|
||||
|
||||
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
|
||||
@@ -244,4 +212,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
},
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
// Utilities and types
|
||||
export {
|
||||
copyToClipboard,detectKeyType,generateMockKeyPair,getKeyIcon,
|
||||
getKeyTypeDisplay,isMacOS,type FilterTab,type PanelMode
|
||||
isMacOS,type FilterTab,type PanelMode
|
||||
} from './utils';
|
||||
|
||||
// Card components
|
||||
|
||||
@@ -7,33 +7,6 @@ import React from 'react';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
|
||||
/**
|
||||
* Generate mock key pair (for fallback when Electron backend is unavailable)
|
||||
*/
|
||||
export const generateMockKeyPair = (type: KeyType, label: string, keySize?: number): { privateKey: string; publicKey: string } => {
|
||||
const typeMap: Record<KeyType, string> = {
|
||||
'ED25519': 'ed25519',
|
||||
'ECDSA': `ecdsa-sha2-nistp${keySize || 256}`,
|
||||
'RSA': 'rsa',
|
||||
};
|
||||
|
||||
const randomId = crypto.randomUUID().replace(/-/g, '').substring(0, 32);
|
||||
|
||||
// Generate size-appropriate random data for more realistic keys
|
||||
const keyLength = type === 'RSA' ? (keySize || 4096) / 8 : 32;
|
||||
const randomData = Array.from(crypto.getRandomValues(new Uint8Array(keyLength)))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACB${randomId}AAAEC${randomData.substring(0, 64)}
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
|
||||
const publicKey = `ssh-${typeMap[type]} AAAAC3NzaC1lZDI1NTE5AAAAI${randomId.substring(0, 20)} ${label}@netcatty`;
|
||||
|
||||
return { privateKey, publicKey };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon element for key source
|
||||
*/
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
/**
|
||||
* Port Forwarding components module
|
||||
* Re-exports all port forwarding sub-components
|
||||
* Re-exports the entries consumed by the top-level port forwarding view.
|
||||
*/
|
||||
|
||||
export {
|
||||
TYPE_DESCRIPTION_KEYS,
|
||||
TYPE_LABEL_KEYS,
|
||||
TYPE_MENU_LABEL_KEYS,
|
||||
TYPE_ICONS,
|
||||
generateRuleLabel,
|
||||
getStatusColor,
|
||||
getTypeColor,
|
||||
getTypeDescription,
|
||||
getTypeLabel,
|
||||
getTypeMenuLabel,
|
||||
} from './utils';
|
||||
|
||||
export { RuleCard } from './RuleCard';
|
||||
export type { RuleCardProps,ViewMode } from './RuleCard';
|
||||
|
||||
export { WizardContent } from './WizardContent';
|
||||
export type { WizardContentProps,WizardStep } from './WizardContent';
|
||||
|
||||
export { EditPanel } from './EditPanel';
|
||||
export type { EditPanelProps } from './EditPanel';
|
||||
|
||||
export { NewFormPanel } from './NewFormPanel';
|
||||
export type { NewFormPanelProps } from './NewFormPanel';
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* Port Forwarding utilities and constants
|
||||
*/
|
||||
import { Globe,Server,Shuffle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { PortForwardingType } from '../../domain/models';
|
||||
|
||||
export const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.local',
|
||||
remote: 'pf.type.remote',
|
||||
dynamic: 'pf.type.dynamic',
|
||||
};
|
||||
|
||||
export const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.menu.local',
|
||||
remote: 'pf.type.menu.remote',
|
||||
dynamic: 'pf.type.menu.dynamic',
|
||||
};
|
||||
|
||||
export const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
|
||||
const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
|
||||
local: 'pf.type.local.desc',
|
||||
remote: 'pf.type.remote.desc',
|
||||
dynamic: 'pf.type.dynamic.desc',
|
||||
@@ -44,12 +42,6 @@ export function getTypeDescription(
|
||||
return t(TYPE_DESCRIPTION_KEYS[type]);
|
||||
}
|
||||
|
||||
export const TYPE_ICONS: Record<PortForwardingType, React.ReactNode> = {
|
||||
local: <Globe size={16} />,
|
||||
remote: <Server size={16} />,
|
||||
dynamic: <Shuffle size={16} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status color class for a rule
|
||||
*/
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
buildLocalVaultPayload,
|
||||
buildSyncPayload,
|
||||
applySyncPayload,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
} from "../../../application/syncPayload";
|
||||
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
import { CloudSyncSettings } from "../../CloudSyncSettings";
|
||||
import { SettingsTabContent } from "../settings-ui";
|
||||
@@ -35,28 +34,7 @@ export default function SettingsSyncTab(props: {
|
||||
const { t } = useI18n();
|
||||
|
||||
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
// initialization hasn't finished yet. Read from localStorage directly
|
||||
// to avoid uploading empty arrays and overwriting the remote snapshot.
|
||||
let effectiveRules = portForwardingRules;
|
||||
if (effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
// Strip transient per-device fields (status, error, lastUsedAt)
|
||||
// that setGlobalRules persists to localStorage but shouldn't be
|
||||
// included in the cloud sync snapshot.
|
||||
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
|
||||
...rest,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return effectiveRules;
|
||||
return getEffectivePortForwardingRulesForSync(portForwardingRules) ?? [];
|
||||
}, [portForwardingRules]);
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
export { ModelSelector } from "./ModelSelector";
|
||||
export { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { CopilotCliCard } from "./CopilotCliCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
@@ -104,12 +104,6 @@ export const useActiveTabId = (side: "left" | "right"): string | null => {
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to check if a specific pane is active (for CSS control)
|
||||
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
|
||||
const activeTabId = useActiveTabId(side);
|
||||
return activeTabId === paneId || (activeTabId === null && paneId !== null);
|
||||
};
|
||||
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { TransferTask } from "../../types";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import type { TextEditorModalSnapshot } from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
import { SftpConflictDialog } from "./SftpConflictDialog";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
|
||||
import { SftpTransferQueue } from "./SftpTransferQueue";
|
||||
|
||||
type SftpState = ReturnType<typeof useSftpState>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
|
||||
import { SftpHostPicker } from "./index";
|
||||
import { SftpHostPicker } from "./SftpHostPicker";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
|
||||
@@ -14,10 +14,9 @@ import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpTransferSource } from "./SftpContext";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
|
||||
import { isNavigableDirectory } from "./index";
|
||||
import { buildSftpColumnTemplate, isNavigableDirectory, type ColumnWidths, type SortField, type SortOrder } from "./utils";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./index";
|
||||
import { SftpFileRow } from "./SftpFileRow";
|
||||
import {
|
||||
getSftpListUploadFilesTargetPath,
|
||||
getSftpUploadFilesLabelKey,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/pop
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
import { SftpBreadcrumb } from "./index";
|
||||
import { SftpBreadcrumb } from "./SftpBreadcrumb";
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpBookmark } from "../../domain/models";
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpUpdateHosts,
|
||||
useSftpWritableHosts,
|
||||
} from "./index";
|
||||
} from "./SftpContext";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Re-read bookmarks from localStorage (e.g. after cloud sync import). */
|
||||
export function rehydrateGlobalBookmarks() {
|
||||
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// Rehydrate when another window updates the same localStorage key
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
|
||||
rehydrateGlobalBookmarks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
window.dispatchEvent(new CustomEvent('sftp-bookmarks-changed'));
|
||||
}
|
||||
import {
|
||||
getGlobalSftpBookmarksSnapshot,
|
||||
setGlobalSftpBookmarks,
|
||||
subscribeGlobalSftpBookmarks,
|
||||
} from "../../../application/state/sftp/globalSftpBookmarks";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseGlobalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
@@ -47,7 +13,11 @@ interface UseGlobalSftpBookmarksParams {
|
||||
export const useGlobalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseGlobalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const bookmarks = useSyncExternalStore(
|
||||
subscribeGlobalSftpBookmarks,
|
||||
getGlobalSftpBookmarksSnapshot,
|
||||
getGlobalSftpBookmarksSnapshot,
|
||||
);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
@@ -57,21 +27,11 @@ export const useGlobalSftpBookmarks = ({
|
||||
const addBookmark = useCallback((path: string) => {
|
||||
if (!path) return;
|
||||
if (bookmarks.some((b) => b.path === path)) return;
|
||||
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
|
||||
const label = isRoot
|
||||
? path
|
||||
: path.split(/[\\/]/).filter(Boolean).pop() || path;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path,
|
||||
label,
|
||||
global: true,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
setGlobalSftpBookmarks((prev) => [...prev, createSftpBookmark(path, { global: true })]);
|
||||
}, [bookmarks]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
// ── Shared external store so every hook instance sees the same bookmarks ──
|
||||
|
||||
@@ -47,16 +48,7 @@ export const useLocalSftpBookmarks = ({
|
||||
if (isCurrentPathBookmarked) {
|
||||
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
|
||||
const label = isRoot
|
||||
? currentPath
|
||||
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
setBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Host, SftpBookmark } from "../../../domain/models";
|
||||
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
|
||||
|
||||
interface UseSftpBookmarksParams {
|
||||
host: Host | undefined;
|
||||
@@ -40,16 +41,7 @@ export const useSftpBookmarks = ({
|
||||
if (isCurrentPathBookmarked) {
|
||||
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const label =
|
||||
currentPath === "/"
|
||||
? "/"
|
||||
: currentPath.split("/").filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
updateHostBookmarks([...bookmarks, newBookmark]);
|
||||
updateHostBookmarks([...bookmarks, createSftpBookmark(currentPath)]);
|
||||
}
|
||||
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { joinPath } from "../../../application/state/sftp/utils";
|
||||
|
||||
interface UseSftpPaneDragAndSelectParams {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import type { SortField, SortOrder } from "../utils";
|
||||
import { filterHiddenFiles, sortSftpEntries } from "../index";
|
||||
import { filterHiddenFiles, sortSftpEntries } from "../utils";
|
||||
|
||||
interface UseSftpPaneFilesParams {
|
||||
files: SftpFileEntry[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPane } from "../../../application/state/sftp/types";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
|
||||
interface UseSftpPanePathParams {
|
||||
connection: SftpPane["connection"] | null;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
import { isNavigableDirectory } from "../utils";
|
||||
import { editorTabStore } from "../../../application/state/editorTabStore";
|
||||
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
/**
|
||||
* SFTP Components - Index
|
||||
*
|
||||
* Re-exports all SFTP-related components and utilities for easy importing
|
||||
* Re-exports the SFTP entries consumed by top-level views.
|
||||
*/
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes, formatDate,
|
||||
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, sortSftpEntries, type ColumnWidths, type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
// Context
|
||||
export {
|
||||
SftpContextProvider,
|
||||
useSftpContext,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpWritableHosts,
|
||||
useSftpUpdateHosts,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
type SftpPaneCallbacks,
|
||||
type SftpDragCallbacks,
|
||||
type SftpContextValue,
|
||||
} from './SftpContext';
|
||||
|
||||
// Components
|
||||
export { SftpBreadcrumb } from './SftpBreadcrumb';
|
||||
export { SftpConflictDialog } from './SftpConflictDialog';
|
||||
export { SftpFileRow } from './SftpFileRow';
|
||||
export { SftpHostPicker } from './SftpHostPicker';
|
||||
export { SftpPermissionsDialog } from './SftpPermissionsDialog';
|
||||
export { SftpTabBar, type SftpTab } from './SftpTabBar';
|
||||
export { SftpTransferItem } from './SftpTransferItem';
|
||||
export { SftpTabBar } from './SftpTabBar';
|
||||
|
||||
@@ -329,7 +329,7 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
*
|
||||
* The ".." parent directory entry is never considered hidden.
|
||||
*/
|
||||
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
|
||||
const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
|
||||
file: T,
|
||||
): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
@@ -340,10 +340,6 @@ export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
|
||||
return false;
|
||||
};
|
||||
|
||||
/** @deprecated Use isHiddenFile instead */
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
|
||||
isHiddenFile(file);
|
||||
|
||||
/**
|
||||
* Filter files based on hidden file visibility setting.
|
||||
* Filters Windows hidden files and Unix/Linux dotfiles on all connections.
|
||||
|
||||
@@ -388,17 +388,6 @@ function fuzzyScore(query: string, target: string): number {
|
||||
return queryIdx === query.length ? score : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific command from history for a host.
|
||||
*/
|
||||
export function deleteHistoryEntry(command: string, hostId: string): void {
|
||||
const store = loadStore();
|
||||
store.entries = store.entries.filter(
|
||||
(e) => !(e.command === command && e.hostId === hostId),
|
||||
);
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all history for a specific host, or all history if no hostId given.
|
||||
*/
|
||||
@@ -411,14 +400,3 @@ export function clearHistory(hostId?: string): void {
|
||||
}
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of stored history entries.
|
||||
*/
|
||||
export function getHistoryCount(hostId?: string): number {
|
||||
const store = loadStore();
|
||||
if (hostId) {
|
||||
return store.entries.filter((e) => e.hostId === hostId).length;
|
||||
}
|
||||
return store.entries.length;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
|
||||
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
|
||||
export { recordCommand, clearHistory } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
|
||||
@@ -362,22 +362,3 @@ export function getAlignedPrompt(
|
||||
}
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified prompt detection: just check if we're likely at a prompt.
|
||||
*/
|
||||
export function isLikelyAtPrompt(term: XTerm): boolean {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const line = buffer.getLine(cursorY);
|
||||
if (!line) return false;
|
||||
|
||||
const lineText = line.translateToString(false);
|
||||
if (lineText.trim().length === 0) return false;
|
||||
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return false;
|
||||
}
|
||||
|
||||
return findPromptBoundary(lineText) >= 0;
|
||||
}
|
||||
|
||||
@@ -16,63 +16,4 @@ const Card = React.forwardRef<
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card,CardContent,CardDescription,CardFooter,CardHeader,CardTitle }
|
||||
export { Card }
|
||||
|
||||
@@ -241,6 +241,44 @@ export interface SyncPayload {
|
||||
syncedAt: number; // When this payload was created
|
||||
}
|
||||
|
||||
export const SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'portForwardingRules',
|
||||
'knownHosts',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
|
||||
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
|
||||
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
|
||||
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
|
||||
|
||||
export function hasSyncPayloadEntityData(
|
||||
payload: SyncPayload,
|
||||
keys: readonly SyncPayloadEntityKey[] = SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
): boolean {
|
||||
return keys.some((key) => {
|
||||
const value = payload[key];
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Encryption Types
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SyncPayload } from './sync';
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
type CloudSyncPayloadEntityKey,
|
||||
type SyncPayload,
|
||||
} from './sync';
|
||||
|
||||
export type ShrinkFinding =
|
||||
| { suspicious: false }
|
||||
@@ -22,22 +26,9 @@ export type ShrinkFinding =
|
||||
viaRemote?: boolean;
|
||||
};
|
||||
|
||||
// Keep in sync with all array-typed fields of SyncPayload. When a new
|
||||
// array entity type is added there, add it here too — there is no
|
||||
// compile-time check enforcing this.
|
||||
const CHECKED_ENTITIES = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
const CHECKED_ENTITIES = CLOUD_SYNC_PAYLOAD_ENTITY_KEYS;
|
||||
|
||||
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
|
||||
type CheckedEntityType = CloudSyncPayloadEntityKey;
|
||||
|
||||
const BULK_SHRINK_RATIO = 0.5;
|
||||
const BULK_SHRINK_MIN_ABSOLUTE = 3;
|
||||
|
||||
@@ -55,20 +55,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
|
||||
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
|
||||
|
||||
// Default command blocklist (defense-in-depth, TCP bridge also checks)
|
||||
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
|
||||
const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
|
||||
'\\bmkfs\\.',
|
||||
'\\bdd\\s+if=.*\\s+of=/dev/',
|
||||
'\\b(shutdown|reboot|poweroff|halt)\\b',
|
||||
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:',
|
||||
'>\\s*/dev/sd',
|
||||
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
|
||||
'\\bmv\\s+/\\s',
|
||||
':\\s*>\\s*/etc/',
|
||||
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
];
|
||||
const DEFAULT_COMMAND_BLOCKLIST = require("../../lib/commandBlocklist.cjs");
|
||||
|
||||
// Pre-compile blocklist regexes once at module load time
|
||||
const compiledBlocklist = DEFAULT_COMMAND_BLOCKLIST.map(pattern => {
|
||||
|
||||
19
infrastructure/ai/commandBlocklist.test.ts
Normal file
19
infrastructure/ai/commandBlocklist.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRequire } from "node:module";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { checkCommandSafety } from "./cattyAgent/safety";
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from "./types";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const defaultCommandBlocklist = require("../../lib/commandBlocklist.cjs") as string[];
|
||||
|
||||
test("AI command blocklist uses the shared CommonJS source", () => {
|
||||
assert.deepEqual(DEFAULT_COMMAND_BLOCKLIST, Array.from(defaultCommandBlocklist));
|
||||
});
|
||||
|
||||
test("shared default command blocklist covers bypass-style shell execution", () => {
|
||||
assert.equal(checkCommandSafety("echo ZWNobyBoaQ== | base64 -d | bash").blocked, true);
|
||||
assert.equal(checkCommandSafety("eval $payload").blocked, true);
|
||||
assert.equal(checkCommandSafety("echo $(whoami)").blocked, true);
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
// AI Provider types
|
||||
import * as commandBlocklistModule from '../../lib/commandBlocklist.cjs';
|
||||
import type { ProviderContinuation } from './providerContinuation';
|
||||
|
||||
const commandBlocklistSource = commandBlocklistModule as unknown as {
|
||||
DEFAULT_COMMAND_BLOCKLIST?: string[];
|
||||
default?: string[];
|
||||
};
|
||||
|
||||
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
|
||||
|
||||
export interface ProviderAdvancedParams {
|
||||
@@ -249,23 +255,7 @@ export interface AISettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
// rm with recursive+force in any order/form targeting root
|
||||
'\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}',
|
||||
'\\bmkfs\\.',
|
||||
'\\bdd\\s+if=.*\\s+of=/dev/',
|
||||
'\\b(shutdown|reboot|poweroff|halt)\\b',
|
||||
':\\(\\)\\{\\s*:\\|:\\&\\s*\\};:', // fork bomb
|
||||
'>\\s*/dev/sd',
|
||||
'\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/',
|
||||
'\\bmv\\s+/\\s',
|
||||
':\\s*>\\s*/etc/',
|
||||
'\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b', // piped install with sudo
|
||||
'\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b',
|
||||
// Common bypass techniques (defense-in-depth, not a security boundary)
|
||||
'base64.*\\|.*(?:ba)?sh', // base64 decode piped to shell
|
||||
'\\beval\\b', // eval usage
|
||||
'\\$\\(', // command substitution abuse
|
||||
'`.+`', // backtick command substitution
|
||||
...(commandBlocklistSource.DEFAULT_COMMAND_BLOCKLIST ?? commandBlocklistSource.default ?? []),
|
||||
];
|
||||
|
||||
export const DEFAULT_AI_SETTINGS: AISettings = {
|
||||
|
||||
@@ -100,7 +100,3 @@ export function migrateDeprecatedFontOverride<
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getRawFontFamily(fontId: string): string {
|
||||
return (TERMINAL_FONTS.find((f) => f.id === fontId) || TERMINAL_FONTS[0]).family;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,3 @@ export const UI_FONTS: UIFont[] = BASE_UI_FONTS.map((font) => ({
|
||||
}));
|
||||
|
||||
export const DEFAULT_UI_FONT_ID = 'space-grotesk';
|
||||
|
||||
export const getUiFontById = (id: string): UIFont => {
|
||||
return UI_FONTS.find((f) => f.id === id) || UI_FONTS[0];
|
||||
};
|
||||
|
||||
@@ -133,14 +133,6 @@ export type ResolvedXTermPerformance = {
|
||||
const isLowMemoryDevice = (deviceMemoryGb?: number) =>
|
||||
typeof deviceMemoryGb === "number" && deviceMemoryGb > 0 && deviceMemoryGb <= 4;
|
||||
|
||||
/**
|
||||
* Get platform-specific xterm configuration
|
||||
* @returns Configuration object optimized for the current platform
|
||||
*/
|
||||
export function getXTermConfig(platform: XTermPlatform = "darwin") {
|
||||
return resolveXTermPerformanceConfig({ platform }).options;
|
||||
}
|
||||
|
||||
export type RendererPreference = "auto" | "webgl" | "dom";
|
||||
|
||||
/**
|
||||
|
||||
24
lib/commandBlocklist.cjs
Normal file
24
lib/commandBlocklist.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_COMMAND_BLOCKLIST = [
|
||||
// rm with recursive+force in any order/form targeting root
|
||||
"\\brm\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+(-[a-zA-Z]*f[a-zA-Z]*\\s+)?|-[a-zA-Z]*f[a-zA-Z]*\\s+(-[a-zA-Z]*r[a-zA-Z]*\\s+)?|--recursive\\s+|--force\\s+){1,}",
|
||||
"\\bmkfs\\.",
|
||||
"\\bdd\\s+if=.*\\s+of=/dev/",
|
||||
"\\b(shutdown|reboot|poweroff|halt)\\b",
|
||||
":\\(\\)\\{\\s*:\\|:\\&\\s*\\};:",
|
||||
">\\s*/dev/sd",
|
||||
"\\bchmod\\s+(-[a-zA-Z]*R[a-zA-Z]*|--recursive)\\s+777\\s+/",
|
||||
"\\bmv\\s+/\\s",
|
||||
":\\s*>\\s*/etc/",
|
||||
"\\bcurl\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b",
|
||||
"\\bwget\\s+.*\\|\\s*\\bsudo\\s+\\bbash\\b",
|
||||
// Common bypass techniques (defense-in-depth, not a security boundary)
|
||||
"base64.*\\|.*(?:ba)?sh",
|
||||
"\\beval\\b",
|
||||
"\\$\\(",
|
||||
"`.+`",
|
||||
];
|
||||
|
||||
module.exports = DEFAULT_COMMAND_BLOCKLIST;
|
||||
module.exports.DEFAULT_COMMAND_BLOCKLIST = DEFAULT_COMMAND_BLOCKLIST;
|
||||
@@ -3,7 +3,8 @@
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
const CMD_SHELLS = new Set(["cmd", "cmd.exe"]);
|
||||
const FISH_SHELLS = new Set(["fish"]);
|
||||
const POSIX_SHELLS = new Set(["sh", "bash", "zsh", "ksh", "dash", "ash"]);
|
||||
const POSIX_SHELLS = new Set(["sh", "bash", "zsh", "ksh", "dash", "ash", "bash.exe"]);
|
||||
const WSL_SHELLS = new Set(["wsl", "wsl.exe"]);
|
||||
|
||||
function getExecutableBaseName(filePath) {
|
||||
const normalized = String(filePath || "").trim();
|
||||
@@ -26,6 +27,7 @@ function classifyLocalShellType(shellPath, platformLike) {
|
||||
if (CMD_SHELLS.has(shellName)) return "cmd";
|
||||
if (FISH_SHELLS.has(shellName)) return "fish";
|
||||
if (POSIX_SHELLS.has(shellName)) return "posix";
|
||||
if (WSL_SHELLS.has(shellName)) return "posix";
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
|
||||
}
|
||||
|
||||
37
lib/localShell.test.ts
Normal file
37
lib/localShell.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRequire } from "node:module";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyLocalShellType, detectLocalOs } from "./localShell";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cjsLocalShell = require("./localShell.cjs") as {
|
||||
classifyLocalShellType: typeof classifyLocalShellType;
|
||||
detectLocalOs: typeof detectLocalOs;
|
||||
};
|
||||
|
||||
test("local shell classification is shared between renderer and CommonJS bridge", () => {
|
||||
const cases: Array<[string | undefined, string | undefined, ReturnType<typeof classifyLocalShellType>]> = [
|
||||
["/bin/zsh", "MacIntel", "posix"],
|
||||
["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "Win32", "powershell"],
|
||||
["C:\\Windows\\System32\\cmd.exe", "Win32", "cmd"],
|
||||
["C:\\Windows\\System32\\wsl.exe", "Win32", "posix"],
|
||||
["C:\\msys64\\usr\\bin\\bash.exe", "Win32", "posix"],
|
||||
["fish", "linux", "fish"],
|
||||
["", "Win32", "powershell"],
|
||||
[undefined, "MacIntel", "posix"],
|
||||
["custom-shell", "linux", "unknown"],
|
||||
];
|
||||
|
||||
for (const [shellPath, platform, expected] of cases) {
|
||||
assert.equal(classifyLocalShellType(shellPath, platform), expected);
|
||||
assert.equal(cjsLocalShell.classifyLocalShellType(shellPath, platform), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("local OS detection is shared between renderer and CommonJS bridge", () => {
|
||||
assert.equal(detectLocalOs("MacIntel"), "macos");
|
||||
assert.equal(cjsLocalShell.detectLocalOs("MacIntel"), "macos");
|
||||
assert.equal(detectLocalOs("Win32"), "windows");
|
||||
assert.equal(cjsLocalShell.detectLocalOs("Win32"), "windows");
|
||||
});
|
||||
@@ -1,39 +1,18 @@
|
||||
export type LocalShellType = 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
|
||||
export type LocalOs = 'linux' | 'macos' | 'windows';
|
||||
import * as localShellCore from "./localShell.cjs";
|
||||
|
||||
const POWERSHELL_SHELLS = new Set(['powershell', 'powershell.exe', 'pwsh', 'pwsh.exe']);
|
||||
const CMD_SHELLS = new Set(['cmd', 'cmd.exe']);
|
||||
const FISH_SHELLS = new Set(['fish']);
|
||||
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash', 'bash.exe']);
|
||||
// WSL launcher — runs a Linux shell inside WSL, classify as posix
|
||||
const WSL_SHELLS = new Set(['wsl', 'wsl.exe']);
|
||||
export type LocalShellType = "posix" | "fish" | "powershell" | "cmd" | "unknown";
|
||||
export type LocalOs = "linux" | "macos" | "windows";
|
||||
|
||||
const getExecutableBaseName = (filePath: string | undefined): string => {
|
||||
const normalized = String(filePath || '').trim();
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts[parts.length - 1] || '').toLowerCase();
|
||||
type LocalShellCore = {
|
||||
detectLocalOs: (platformLike?: string) => LocalOs;
|
||||
classifyLocalShellType: (
|
||||
shellPath: string | undefined,
|
||||
platformLike?: string,
|
||||
) => LocalShellType;
|
||||
};
|
||||
|
||||
export const detectLocalOs = (platformLike?: string): LocalOs => {
|
||||
const platform = String(platformLike || '').toLowerCase();
|
||||
if (platform.includes('mac') || platform.includes('darwin')) return 'macos';
|
||||
if (platform.includes('win')) return 'windows';
|
||||
return 'linux';
|
||||
};
|
||||
const core = localShellCore as unknown as LocalShellCore;
|
||||
|
||||
export const classifyLocalShellType = (
|
||||
shellPath: string | undefined,
|
||||
platformLike?: string,
|
||||
): LocalShellType => {
|
||||
const shellName = getExecutableBaseName(shellPath);
|
||||
if (POWERSHELL_SHELLS.has(shellName)) return 'powershell';
|
||||
if (CMD_SHELLS.has(shellName)) return 'cmd';
|
||||
if (FISH_SHELLS.has(shellName)) return 'fish';
|
||||
if (POSIX_SHELLS.has(shellName)) return 'posix';
|
||||
if (WSL_SHELLS.has(shellName)) return 'posix';
|
||||
if (!shellName) {
|
||||
return detectLocalOs(platformLike) === 'windows' ? 'powershell' : 'posix';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
export const detectLocalOs = core.detectLocalOs;
|
||||
|
||||
export const classifyLocalShellType = core.classifyLocalShellType;
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import ts from 'typescript';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
// Find all TSX files recursively
|
||||
function findTsxFiles(dir, files = []) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
|
||||
findTsxFiles(fullPath, files);
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts'))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Organize imports using TypeScript Language Service
|
||||
function organizeImports(filePath) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Create a simple in-memory host
|
||||
const servicesHost = {
|
||||
getScriptFileNames: () => [filePath],
|
||||
getScriptVersion: () => '1',
|
||||
getScriptSnapshot: (fileName) => {
|
||||
if (fileName === filePath) {
|
||||
return ts.ScriptSnapshot.fromString(fileContent);
|
||||
}
|
||||
if (!fs.existsSync(fileName)) {
|
||||
return undefined;
|
||||
}
|
||||
return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, 'utf-8'));
|
||||
},
|
||||
getCurrentDirectory: () => rootDir,
|
||||
getCompilationSettings: () => ({
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
allowJs: true,
|
||||
skipLibCheck: true,
|
||||
noEmit: true,
|
||||
}),
|
||||
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
||||
fileExists: ts.sys.fileExists,
|
||||
readFile: ts.sys.readFile,
|
||||
readDirectory: ts.sys.readDirectory,
|
||||
directoryExists: ts.sys.directoryExists,
|
||||
getDirectories: ts.sys.getDirectories,
|
||||
};
|
||||
|
||||
const services = ts.createLanguageService(servicesHost, ts.createDocumentRegistry());
|
||||
|
||||
// Get organize imports edits
|
||||
const edits = services.organizeImports(
|
||||
{ type: 'file', fileName: filePath },
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
if (edits.length === 0) {
|
||||
return { changed: false, content: fileContent };
|
||||
}
|
||||
|
||||
// Apply edits
|
||||
let newContent = fileContent;
|
||||
// Apply edits in reverse order to preserve positions
|
||||
for (const fileEdit of edits) {
|
||||
const textChanges = [...fileEdit.textChanges].sort((a, b) => b.span.start - a.span.start);
|
||||
for (const change of textChanges) {
|
||||
newContent =
|
||||
newContent.slice(0, change.span.start) +
|
||||
change.newText +
|
||||
newContent.slice(change.span.start + change.span.length);
|
||||
}
|
||||
}
|
||||
|
||||
return { changed: newContent !== fileContent, content: newContent };
|
||||
}
|
||||
|
||||
// Main
|
||||
console.log('🔍 Searching for TSX/TS files...\n');
|
||||
const files = findTsxFiles(rootDir);
|
||||
console.log(`Found ${files.length} files to process.\n`);
|
||||
|
||||
let changedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(rootDir, file);
|
||||
try {
|
||||
const result = organizeImports(file);
|
||||
if (result.changed) {
|
||||
fs.writeFileSync(file, result.content, 'utf-8');
|
||||
console.log(`✅ Fixed: ${relativePath}`);
|
||||
changedCount++;
|
||||
} else {
|
||||
console.log(`⏭️ Skipped (no changes): ${relativePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing ${relativePath}: ${error.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`📊 Summary:`);
|
||||
console.log(` Files processed: ${files.length}`);
|
||||
console.log(` Files modified: ${changedCount}`);
|
||||
console.log(` Errors: ${errorCount}`);
|
||||
console.log('='.repeat(50));
|
||||
Reference in New Issue
Block a user