Clean up dead code and duplicated helpers (#1001)

This commit is contained in:
陈大猫
2026-05-18 20:00:10 +08:00
committed by GitHub
parent 6b8f05c65a
commit b30696c98b
55 changed files with 378 additions and 895 deletions

24
App.tsx
View File

@@ -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 });

View File

@@ -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);
};

View 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 } : {}),
};
}

View 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();
}
});
}

View File

@@ -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,
};
}, [

View File

@@ -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
};
};

View File

@@ -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?.();
}
});

View File

@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,

View File

@@ -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],
);

View File

@@ -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>
);
})}

View File

@@ -73,7 +73,7 @@ interface TextEditorModalProps {
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,

View File

@@ -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

View File

@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
);
};
export default TrafficDiagram;

View File

@@ -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 &&

View File

@@ -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 };

View File

@@ -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';

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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';

View File

@@ -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
*/

View File

@@ -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 => {

View File

@@ -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";

View File

@@ -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[];

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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";

View File

@@ -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';

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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 }

View File

@@ -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
// ============================================================================

View File

@@ -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;

View File

@@ -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 => {

View 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);
});

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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];
};

View File

@@ -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
View 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;

View File

@@ -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
View 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");
});

View File

@@ -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;

View File

@@ -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));