Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 c98e688ad6 refactor(sftp): align local bookmarks store API with global bookmarks
Export subscribe/get/set/rehydrate helpers from localSftpBookmarks to match
the globalSftpBookmarks external-store pattern.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:29:36 +00:00
Cursor Agentand陈大猫 5c2d60f694 refactor: migrate architecture-debt hooks into application/state
Move SFTP Wave A stores/hooks and related vault/AI/terminal/systemManager
hooks out of components into application/state, update importers, and keep
thin deprecated re-exports at the old paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 03:28:56 +00:00
52 changed files with 3126 additions and 3024 deletions

No files matched your search

@@ -0,0 +1,83 @@
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 "./bookmarkHelpers";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
export function subscribeLocalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getLocalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateLocalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setLocalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) {
rehydrateLocalSftpBookmarks();
}
});
}
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
getLocalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setLocalSftpBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
@@ -0,0 +1,124 @@
/**
* SFTP Clipboard Store
*
* Manages clipboard state for SFTP file operations (copy/cut/paste)
* This is a simple store that holds the clipboard state and operation type.
*/
import { useSyncExternalStore } from "react";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;
sourceSide: "left" | "right";
operation: SftpClipboardOperation;
}
type ClipboardListener = () => void;
let clipboardState: SftpClipboardState | null = null;
const clipboardListeners = new Set<ClipboardListener>();
const notifyListeners = () => {
clipboardListeners.forEach((listener) => listener());
};
export const sftpClipboardStore = {
getSnapshot: (): SftpClipboardState | null => clipboardState,
subscribe: (listener: ClipboardListener) => {
clipboardListeners.add(listener);
return () => clipboardListeners.delete(listener);
},
/**
* Copy files to clipboard
*/
copy: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "copy",
};
notifyListeners();
},
/**
* Cut files to clipboard
*/
cut: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "cut",
};
notifyListeners();
},
/**
* Clear clipboard (called after paste for cut operation)
*/
clear: () => {
clipboardState = null;
notifyListeners();
},
/**
* Update clipboard file list (used for partial cut transfers)
*/
updateFiles: (files: SftpClipboardFile[]) => {
if (!clipboardState) return;
if (files.length === 0) {
clipboardState = null;
} else {
clipboardState = {
...clipboardState,
files,
};
}
notifyListeners();
},
/**
* Check if there are files in the clipboard
*/
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
/**
* Get the clipboard state
*/
get: (): SftpClipboardState | null => clipboardState,
};
/**
* React hook to subscribe to clipboard state changes
*/
export const useSftpClipboard = (): SftpClipboardState | null => {
return useSyncExternalStore(
sftpClipboardStore.subscribe,
sftpClipboardStore.getSnapshot,
sftpClipboardStore.getSnapshot
);
};
@@ -0,0 +1,124 @@
/**
* SFTP Dialog Action Store
*
* Manages dialog action triggers for SFTP operations.
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./sftpFocusStore";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
type ActionListener = () => void;
let dialogAction: SftpDialogAction | null = null;
const actionListeners = new Set<ActionListener>();
const notifyListeners = () => {
actionListeners.forEach((listener) => listener());
};
export const sftpDialogActionStore = {
getSnapshot: (): SftpDialogAction | null => dialogAction,
subscribe: (listener: ActionListener) => {
actionListeners.add(listener);
return () => actionListeners.delete(listener);
},
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
}
notifyListeners();
},
/**
* Clear the current action (called after a pane handles it)
*/
clear: () => {
dialogAction = null;
notifyListeners();
},
/**
* Get the current action
*/
get: (): SftpDialogAction | null => dialogAction,
};
/**
* React hook to subscribe to dialog action changes
*/
export const useSftpDialogAction = (): SftpDialogAction | null => {
return useSyncExternalStore(
sftpDialogActionStore.subscribe,
sftpDialogActionStore.getSnapshot,
sftpDialogActionStore.getSnapshot
);
};
/**
* React hook for a pane to respond to dialog actions
* Only the pane matching the targetSide will execute the callback
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
case "rename":
if (handlers.onRename && action.targetFiles?.[0]) {
handlers.onRename(action.targetFiles[0]);
}
break;
case "delete":
if (handlers.onDelete && action.targetFiles) {
handlers.onDelete(action.targetFiles);
}
break;
case "newFolder":
handlers.onNewFolder?.();
break;
case "newFile":
handlers.onNewFile?.();
break;
}
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, scopeId, handlers, isActive]);
};
+54
View File
@@ -0,0 +1,54 @@
/**
* SFTP Focused Pane Store
*
* Tracks which SFTP pane (left or right) is currently focused.
* This is used to determine which pane should receive keyboard shortcut actions.
*/
import { useSyncExternalStore } from "react";
export type SftpFocusedSide = "left" | "right";
type FocusListener = () => void;
let focusedSide: SftpFocusedSide = "left";
const focusListeners = new Set<FocusListener>();
const notifyListeners = () => {
focusListeners.forEach((listener) => listener());
};
export const sftpFocusStore = {
getSnapshot: (): SftpFocusedSide => focusedSide,
subscribe: (listener: FocusListener) => {
focusListeners.add(listener);
return () => focusListeners.delete(listener);
},
/**
* Set the focused side
*/
setFocusedSide: (side: SftpFocusedSide) => {
if (focusedSide !== side) {
focusedSide = side;
notifyListeners();
}
},
/**
* Get the current focused side
*/
getFocusedSide: (): SftpFocusedSide => focusedSide,
};
/**
* React hook to subscribe to focused side changes
*/
export const useSftpFocusedSide = (): SftpFocusedSide => {
return useSyncExternalStore(
sftpFocusStore.subscribe,
sftpFocusStore.getSnapshot,
sftpFocusStore.getSnapshot
);
};
@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}
@@ -0,0 +1,153 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_SFTP_COLUMN_VISIBILITY,
normalizeSftpColumnVisibility,
type ColumnWidths,
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from "../../../components/sftp/utils";
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../../infrastructure/persistence/localStorageAdapter";
import { useSftpDirectoriesFirst } from "./useSftpDirectoriesFirst";
export interface UseSftpPaneSortingResult {
sortField: SortField;
sortOrder: SortOrder;
directoriesFirst: boolean;
columnWidths: ColumnWidths;
visibleColumns: SftpColumnVisibility;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
toggleColumnVisibility: (field: keyof ColumnWidths) => void;
toggleDirectoriesFirst: () => void;
}
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 56,
modified: 28,
size: 7,
type: 9,
});
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
),
);
useEffect(() => {
const syncVisibleColumns = (event: Event) => {
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
const next = normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
);
setVisibleColumns(next);
};
window.addEventListener("storage", syncVisibleColumns);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
return () => {
window.removeEventListener("storage", syncVisibleColumns);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
};
}, []);
useEffect(() => {
if (visibleColumns[sortField]) return;
setSortField("name");
setSortOrder("asc");
}, [sortField, visibleColumns]);
const toggleColumnVisibility = useCallback((field: keyof ColumnWidths) => {
if (field === "name") return;
setVisibleColumns((current) => {
const next = {
...DEFAULT_SFTP_COLUMN_VISIBILITY,
...current,
name: true,
[field]: !current[field],
};
localStorageAdapter.write(STORAGE_KEY_SFTP_VISIBLE_COLUMNS, next);
return next;
});
}, []);
const resizingRef = useRef<{
field: keyof ColumnWidths;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
}, [sortField]);
const rafIdRef = useRef<number | null>(null);
const lastClientXRef = useRef(0);
const applyColumnWidth = useCallback(() => {
if (!resizingRef.current) return;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
name: { min: 36, max: 78 },
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
min,
Math.min(max, startWidth + diff / 8),
);
setColumnWidths((prev) => ({
...prev,
[field]: newWidth,
}));
}, []);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
lastClientXRef.current = e.clientX;
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
applyColumnWidth();
});
}, [applyColumnWidth]);
const handleResizeEnd = useCallback(() => {
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
applyColumnWidth();
rafIdRef.current = null;
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [applyColumnWidth, handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
e: React.MouseEvent,
) => {
e.preventDefault();
e.stopPropagation();
lastClientXRef.current = e.clientX;
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
directoriesFirst,
columnWidths,
visibleColumns,
handleSort,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
};
};
@@ -0,0 +1,269 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
export interface AsyncRecordState<T> {
data: T | null;
loading: boolean;
error: string | null;
updatedAt: number | null;
}
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
interface UseAsyncRecordCacheOptions<TItem, TValue> {
items: TItem[];
enabled: boolean;
getKey: (item: TItem) => string;
fetchRecord: (item: TItem) => Promise<TValue | null>;
prefetchLimit?: number;
prefetchDelayMs?: number;
staleTimeMs?: number;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleIdleTask(callback: () => void): () => void {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 80);
return () => window.clearTimeout(id);
}
function normalizeRecordError(error: unknown): string {
return error instanceof Error ? error.message : String(error || 'Unknown error');
}
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
if (!record || record.error || record.updatedAt === null) return false;
if (!Number.isFinite(staleTimeMs)) return true;
return Date.now() - record.updatedAt < staleTimeMs;
}
const EMPTY_RECORDS = {};
export function useAsyncRecordCache<TItem, TValue>({
items,
enabled,
getKey,
fetchRecord,
prefetchLimit = 64,
prefetchDelayMs = 16,
staleTimeMs = 30_000,
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
const recordsRef = useRef<RecordMap<TValue>>(records);
const enabledRef = useRef(enabled);
const inflightRef = useRef(new Set<string>());
const requestVersionRef = useRef(new Map<string, number>());
const queuedForceRef = useRef(new Set<string>());
const loadRecordRef = useRef<(
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => Promise<void>>(async () => {});
recordsRef.current = records;
enabledRef.current = enabled;
const commitRecords = useCallback((
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
urgent = false,
) => {
const apply = () => {
setRecords((prev) => {
const next = updater(prev);
recordsRef.current = next;
return next;
});
};
if (urgent) {
apply();
return;
}
startTransition(apply);
}, []);
const loadRecord = useCallback(async (
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => {
if (!enabledRef.current) return;
const key = getKey(item);
if (!key) return;
if (inflightRef.current.has(key)) {
if (options?.force) {
queuedForceRef.current.add(key);
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
}
return;
}
const existing = recordsRef.current[key];
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
return;
}
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
requestVersionRef.current.set(key, requestVersion);
inflightRef.current.add(key);
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: true,
error: null,
updatedAt: prev[key]?.updatedAt ?? null,
},
}), options?.urgent);
try {
const data = await fetchRecord(item);
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data,
loading: false,
error: null,
updatedAt: Date.now(),
},
}));
} catch (error) {
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: false,
error: normalizeRecordError(error),
updatedAt: prev[key]?.updatedAt ?? null,
},
}));
} finally {
inflightRef.current.delete(key);
if (queuedForceRef.current.has(key)) {
if (enabledRef.current) {
queuedForceRef.current.delete(key);
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
} else {
commitRecords((prev) => {
const current = prev[key];
if (!current?.loading) return prev;
return {
...prev,
[key]: {
...current,
loading: false,
},
};
}, true);
}
}
}
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
loadRecordRef.current = loadRecord;
useEffect(() => {
const itemKeys = new Set(items.map(getKey).filter(Boolean));
for (const key of queuedForceRef.current) {
if (!itemKeys.has(key)) {
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (!itemKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
});
}, [commitRecords, getKey, items]);
useEffect(() => {
if (!enabled || queuedForceRef.current.size === 0) return;
for (const item of items) {
const key = getKey(item);
if (!key || !queuedForceRef.current.has(key)) continue;
queuedForceRef.current.delete(key);
void loadRecord(item, { force: true, urgent: true });
}
}, [enabled, getKey, items, loadRecord]);
useEffect(() => {
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
let cancelled = false;
const candidates = items.slice(0, prefetchLimit);
const cancelIdleTask = scheduleIdleTask(() => {
void (async () => {
for (const item of candidates) {
if (cancelled) return;
await loadRecord(item);
if (prefetchDelayMs > 0) {
await delay(prefetchDelayMs);
}
}
})();
});
return () => {
cancelled = true;
cancelIdleTask();
};
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
const invalidateRecord = useCallback((key: string) => {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
commitRecords((prev) => {
if (!(key in prev)) return prev;
const { [key]: _removed, ...next } = prev;
return next;
}, true);
}, [commitRecords]);
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
for (const key of requestVersionRef.current.keys()) {
if (matches(key)) {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (matches(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
}, true);
}, [commitRecords]);
const refreshRecord = useCallback(
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
[loadRecord],
);
return {
records,
loadRecord,
refreshRecord,
invalidateRecord,
invalidateMatching,
};
}
+268
View File
@@ -0,0 +1,268 @@
/**
* useAIChatStreaming — React UI layer for AI chat streaming.
*
* Turn orchestration lives in AgentRuntime + TurnDrivers; this hook only
* manages streaming state, abort controllers, and UI callbacks.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import type {
TurnSteerInput,
TurnSteerResult,
} from '../../infrastructure/ai/harness/turnDrivers/types';
import { classifyError } from '../../infrastructure/ai/errorClassifier';
import { latestAISessionsSnapshot } from './aiStateSnapshots';
import {
generateId,
getNetcattyBridge,
type DefaultTargetSessionHint,
type TerminalSessionInfo,
} from '../../components/ai/hooks/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
export { getNetcattyBridge } from '../../components/ai/hooks/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
export type { DefaultTargetSessionHint } from '../../components/ai/hooks/aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
/** Whether a chat session still has an active stream (used to keep panel mounted while hidden). */
export function isAIChatSessionStreaming(sessionId: string | null | undefined): boolean {
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
export interface UseAIChatStreamingParams {
maxIterations: number;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../infrastructure/ai/types').AISessionContextCompaction,
) => void;
}
export interface UseAIChatStreamingReturn {
streamingSessionIds: Set<string>;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
sendToCattyAgent: (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
sendToExternalAgent: (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => Promise<void>;
steerExternalAgent: (input: TurnSteerInput) => Promise<TurnSteerResult>;
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
activeCompaction: import('./useAgentCompactionUi').ActiveCompactionUi | null;
}
export interface SendToCattyContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
commandTimeout?: number;
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
titleText?: string;
selectedUserSkillSlugs?: string[];
permissionMode?: AIPermissionMode;
forceCompaction?: boolean;
}
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
permissionMode: AIPermissionMode;
}
export function useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
const activeCompaction = useAgentCompactionUi();
const reportStreamError = useCallback((
sessionId: string,
abortSignal: AbortSignal,
err: unknown,
) => {
if (abortSignal.aborted) return;
console.error('[AIChatSidePanel] Stream error (full):', err);
const errorInfo = classifyError(err);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo,
timestamp: Date.now(),
});
}, [updateLastMessage, addMessageToSession]);
const uiCallbacks = useCallback(() => ({
addMessageToSession,
updateLastMessage,
updateMessageById,
reportStreamError,
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
await getAgentRuntime().runTurn({
backend: 'external-sdk',
chatSessionId: sessionId,
assistantMsgId,
userText: trimmed,
signal: abortController.signal,
agentConfig,
attachedImages,
context,
bridge,
ui: uiCallbacks(),
});
}, [uiCallbacks]);
const steerExternalAgent = useCallback(async (input: TurnSteerInput) => {
return getAgentRuntime().steerTurn(input);
}, []);
const sendToCattyAgent = useCallback(async (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
try {
await getAgentRuntime().runTurn({
backend: 'catty',
chatSessionId: sessionId,
sendScopeKey,
userText: trimmed,
signal: abortController.signal,
currentSession,
assistantMsgId,
context,
attachments,
maxIterations,
bridge,
ui: uiCallbacks(),
});
} finally {
abortControllersRef.current.delete(sessionId);
}
}, [maxIterations, uiCallbacks]);
return {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
sendToCattyAgent,
sendToExternalAgent,
steerExternalAgent,
reportStreamError,
activeCompaction,
};
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import type { ContextPrepareTrigger } from '../../infrastructure/ai/harness/types';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { CATTY_COMPACTION_STATUS_KEYS } from '../../infrastructure/ai/harness/compactionStatusKeys';
export interface ActiveCompactionUi {
sessionId: string;
trigger: ContextPrepareTrigger;
}
export interface AgentContextUsage {
sessionId: string;
inputTokens: number;
contextWindow: number;
estimated: boolean;
}
function statusKeyForTrigger(trigger: ContextPrepareTrigger): string {
switch (trigger) {
case 'step':
return CATTY_COMPACTION_STATUS_KEYS.step;
case '413-retry':
return CATTY_COMPACTION_STATUS_KEYS.retry;
default:
return CATTY_COMPACTION_STATUS_KEYS.preTurn;
}
}
export function useAgentCompactionUi(): ActiveCompactionUi | null {
const [active, setActive] = useState<ActiveCompactionUi | null>(null);
useEffect(() => {
const unsubscribe = getAgentRuntime().subscribe((event) => {
const sessionId = event.chatSessionId ?? event.sessionId;
if (event.type === 'compaction_start') {
setActive({ sessionId, trigger: event.trigger });
return;
}
if (event.type === 'compaction' || event.type === 'turn_end') {
setActive((prev) => (prev?.sessionId === sessionId ? null : prev));
}
});
return unsubscribe;
}, []);
return active;
}
export function compactionStatusText(
trigger: ContextPrepareTrigger,
translate: (key: string, params?: Record<string, string | number>) => string,
): string {
return translate(statusKeyForTrigger(trigger));
}
export function resolveCompactionStatusText(
statusText: string | undefined,
translate: (key: string) => string,
): string | undefined {
if (!statusText) return undefined;
if (statusText.startsWith('ai.')) return translate(statusText);
return statusText;
}
export function useAgentContextUsage(sessionId: string | null | undefined): AgentContextUsage | null {
const [usage, setUsage] = useState<AgentContextUsage | null>(null);
useEffect(() => {
if (!sessionId) {
setUsage(null);
return undefined;
}
const unsubscribe = getAgentRuntime().subscribe((event) => {
const eventSessionId = event.chatSessionId ?? event.sessionId;
if (eventSessionId !== sessionId || event.type !== 'context_snapshot') return;
const snapshot = event.snapshot;
if (
typeof snapshot.contextWindow !== 'number'
|| typeof snapshot.estimatedInputTokens !== 'number'
) {
return;
}
setUsage({
sessionId,
inputTokens: Math.max(0, snapshot.estimatedInputTokens),
contextWindow: Math.max(1, snapshot.contextWindow),
estimated: true,
});
});
return unsubscribe;
}, [sessionId]);
return usage?.sessionId === sessionId ? usage : null;
}
+415
View File
@@ -0,0 +1,415 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
kernelRelease?: string; // Remote kernel release
uptimeSeconds?: number | null;// Remote uptime in seconds
loadAverage?: number[]; // 1/5/15 minute load average
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
interface ServerStatsState {
stats: ServerStats;
isLoading: boolean;
error: string | null;
}
interface ServerStatsClient {
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
type ServerStatsListener = (state: ServerStatsState) => void;
interface SharedServerStatsSession {
sessionId: string;
state: ServerStatsState;
listeners: Set<ServerStatsListener>;
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
consecutiveFailures: number;
givenUp: boolean;
pollIntervalMs: number | null;
pollingActive: boolean;
inflight: Promise<void> | null;
inflightGeneration: number | null;
}
const CONSECUTIVE_FAILURE_LIMIT = 3;
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
let nextClientId = 1;
function createEmptyServerStats(): ServerStats {
return {
hostname: undefined,
osName: undefined,
kernelRelease: undefined,
uptimeSeconds: null,
loadAverage: [],
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
};
}
function createInitialState(): ServerStatsState {
return {
stats: createEmptyServerStats(),
isLoading: false,
error: null,
};
}
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
const existing = serverStatsSessions.get(sessionId);
if (existing) return existing;
const session: SharedServerStatsSession = {
sessionId,
state: createInitialState(),
listeners: new Set(),
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
consecutiveFailures: 0,
givenUp: false,
pollIntervalMs: null,
pollingActive: false,
inflight: null,
inflightGeneration: null,
};
serverStatsSessions.set(sessionId, session);
return session;
}
function emitServerStatsState(session: SharedServerStatsSession): void {
for (const listener of session.listeners) {
listener(session.state);
}
}
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
session.state = { ...session.state, ...patch };
emitServerStatsState(session);
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
}
if (session.intervalRef) {
clearInterval(session.intervalRef);
session.intervalRef = null;
}
session.pollingActive = false;
session.pollIntervalMs = null;
}
function resetServerStatsSession(session: SharedServerStatsSession): void {
clearServerStatsTimers(session);
session.connectedAt = 0;
session.hasFetched = false;
session.fetchGeneration += 1;
session.consecutiveFailures = 0;
session.givenUp = false;
updateServerStatsState(session, createInitialState());
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
));
}
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
return {
hostname: stats.hostname,
osName: stats.osName,
kernelRelease: stats.kernelRelease,
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
memBuffers: stats.memBuffers ?? null,
memCached: stats.memCached ?? null,
swapTotal: stats.swapTotal ?? null,
swapUsed: stats.swapUsed ?? null,
topProcesses: stats.topProcesses || [],
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
netInterfaces: stats.netInterfaces || [],
lastUpdated: Date.now(),
};
}
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
session.consecutiveFailures += 1;
updateServerStatsState(session, { error: message });
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. This covers network devices that advertise
// OpenSSH but do not support the POSIX stats shell command.
session.givenUp = true;
clearServerStatsTimers(session);
}
}
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
if (!session.sessionId) return;
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
await session.inflight;
return;
}
if (session.givenUp && !force) return;
if (getActiveServerStatsClients(session).length === 0) return;
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return;
if (force) {
session.givenUp = false;
session.consecutiveFailures = 0;
}
const generation = session.fetchGeneration;
session.inflightGeneration = generation;
updateServerStatsState(session, { isLoading: true, error: null });
const request = (async () => {
try {
const result = await bridge.getServerStats(session.sessionId);
// Discard stale responses from before a polling restart or reconnect.
if (generation !== session.fetchGeneration) return;
if (result.pending) {
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
// has not finished. Do not count this toward the hard-failure cutoff.
return;
}
if (result.success && result.stats) {
session.hasFetched = true;
session.consecutiveFailures = 0;
updateServerStatsState(session, {
stats: normalizeServerStats(result.stats),
error: null,
});
} else if (result.error) {
markServerStatsFailure(session, result.error);
} else {
markServerStatsFailure(session, 'No stats returned');
}
} catch (err) {
if (generation === session.fetchGeneration) {
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (generation === session.fetchGeneration) {
updateServerStatsState(session, { isLoading: false });
}
}
})();
session.inflight = request;
try {
await request;
} finally {
if (session.inflight === request) {
session.inflight = null;
session.inflightGeneration = null;
}
}
}
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
const activeClients = getActiveServerStatsClients(session);
if (activeClients.length === 0) {
resetServerStatsSession(session);
return;
}
if (session.connectedAt === 0) {
session.connectedAt = Date.now();
}
const intervalMs = Math.max(5, Math.min(...activeClients.map((client) => client.refreshInterval))) * 1000;
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
if (!shouldRestartPolling) return;
if (session.givenUp) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
session.pollingActive = true;
session.pollIntervalMs = intervalMs;
const connectionAge = Date.now() - session.connectedAt;
const needsWarmup = !session.hasFetched && connectionAge < 2000;
session.initialTimerRef = setTimeout(() => {
session.initialTimerRef = null;
void fetchSharedServerStats(session);
}, needsWarmup ? 2000 : 0);
session.intervalRef = setInterval(() => {
void fetchSharedServerStats(session);
}, intervalMs);
}
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
if (session.clients.size > 0 || session.listeners.size > 0) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
serverStatsSessions.delete(session.sessionId);
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const clientIdRef = useRef<number | null>(null);
if (clientIdRef.current === null) {
clientIdRef.current = nextClientId;
nextClientId += 1;
}
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
const listener: ServerStatsListener = (nextState) => {
setState(nextState);
};
session.listeners.add(listener);
setState(session.state);
return () => {
session.listeners.delete(listener);
session.clients.delete(clientId);
reconcileSharedServerStatsSession(session);
maybeDisposeSharedServerStatsSession(session);
};
}, [sessionId]);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
session.clients.set(clientId, {
enabled,
refreshInterval,
isSupportedOs,
isConnected,
});
setState(session.state);
reconcileSharedServerStatsSession(session);
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected]);
const refresh = useCallback(() => {
const session = getSharedServerStatsSession(sessionId);
void fetchSharedServerStats(session, true).finally(() => {
reconcileSharedServerStatsSession(session);
});
}, [sessionId]);
return {
stats: state.stats,
isLoading: state.isLoading,
error: state.error,
refresh,
};
}
+326
View File
@@ -0,0 +1,326 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import type { I18nContextValue } from '../i18n/I18nProvider';
import { sessionCapabilitiesStore } from './sessionCapabilitiesStore';
import type { SessionCapabilities } from '../domain/systemManager/types';
import type { useSystemManagerBackend } from './useSystemManagerBackend';
import { nextPollData } from '../../components/systemManager/listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const lower = message.toLowerCase();
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
return t('systemManager.errors.sshChannelUnavailable');
}
return message;
}
/** Stable i18n ref so polling fetchers do not reset when locale re-renders. */
export function useStableTranslate(): I18nContextValue['t'] {
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
return useCallback(
(key, values) => tRef.current(key, values),
[],
);
}
export function useSessionCapabilities(
sessionId: string | null,
isConnected: boolean,
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
);
const [probing, setProbing] = useState(false);
useEffect(() => {
if (!sessionId) return undefined;
return sessionCapabilitiesStore.subscribe(sessionId, () => {
setCapabilities(sessionCapabilitiesStore.get(sessionId));
});
}, [sessionId]);
useEffect(() => {
if (!sessionId || isConnected) return undefined;
sessionCapabilitiesStore.delete(sessionId);
return undefined;
}, [sessionId, isConnected]);
const probe = useCallback(async (force = false) => {
if (!sessionId || !isConnected) return;
if (!force && sessionCapabilitiesStore.get(sessionId)) return;
setProbing(true);
try {
const result = await backend.probeSystemCapabilities(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
} finally {
setProbing(false);
}
}, [backend, isConnected, sessionId]);
useEffect(() => {
if (!sessionId || !isConnected || !enabled) return undefined;
void probe();
return undefined;
}, [enabled, sessionId, isConnected, probe]);
return { capabilities, probing, refreshCapabilities: () => probe(true) };
}
/** Prefetch capabilities only for the given session ids (e.g. when System panel opens). */
export function useSystemCapabilitiesWarmup(
sessionIds: string[],
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const backendRef = useRef(backend);
backendRef.current = backend;
const inflightRef = useRef(new Set<string>());
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
useEffect(() => {
if (!sessionKey) return undefined;
for (const sessionId of sessionKey.split(',')) {
if (!sessionId || sessionCapabilitiesStore.get(sessionId)) continue;
if (inflightRef.current.has(sessionId)) continue;
inflightRef.current.add(sessionId);
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
inflightRef.current.delete(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
});
}
return undefined;
}, [sessionKey]);
}
export function usePolling<T>(
fetcher: () => Promise<T | null>,
intervalMs: number,
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
options?: { poll?: boolean; resetKey?: string },
) {
const stableT = useStableTranslate();
const resetKey = options?.resetKey ?? '';
const [data, setData] = useState<T | null>(null);
const [dataKey, setDataKey] = useState(resetKey);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState(resetKey);
const [loading, setLoading] = useState(false);
const [loadingKey, setLoadingKey] = useState(resetKey);
const failuresRef = useRef(0);
const hasDataRef = useRef(false);
const enabledRef = useRef(enabled);
const generationRef = useRef(0);
const runIdRef = useRef(0);
const loadingRunIdRef = useRef(0);
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
const queuedRunRef = useRef<{
options?: { withLoading?: boolean; minLoadingMs?: number };
resolve: () => void;
} | null>(null);
const fetcherRef = useRef(fetcher);
const mergeRef = useRef(merge);
const pollRef = useRef(options?.poll ?? true);
const resetKeyRef = useRef(resetKey);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
enabledRef.current = enabled;
fetcherRef.current = fetcher;
mergeRef.current = merge;
pollRef.current = options?.poll ?? true;
const clearPollTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const pollDelayMs = useCallback(() => {
if (failuresRef.current >= 3) return intervalMs * 4;
return intervalMs;
}, [intervalMs]);
const resolveQueuedRun = useCallback(() => {
queuedRunRef.current?.resolve();
queuedRunRef.current = null;
}, []);
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
const generation = generationRef.current;
const runResetKey = resetKeyRef.current;
if (!enabledRef.current) return;
if (inflightRef.current?.generation === generation) {
if (options?.withLoading) {
queuedRunRef.current?.resolve();
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(true);
return new Promise<void>((resolve) => {
queuedRunRef.current = { options, resolve };
});
}
return;
}
const runId = ++runIdRef.current;
inflightRef.current = { generation, runId };
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
const isCurrent = () => (
generationRef.current === generation
&& enabledRef.current
&& inflightRef.current?.runId === runId
&& resetKeyRef.current === runResetKey
);
if (showLoading) {
loadingRunIdRef.current = runId;
setLoadingKey(runResetKey);
setLoading(true);
}
try {
const result = await fetcherRef.current();
if (!isCurrent()) return;
if (result !== null) {
setDataKey(runResetKey);
setData((prev) => {
const mergeFn = mergeRef.current;
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
if (next !== prev) hasDataRef.current = true;
return next;
});
setErrorKey(runResetKey);
setError(null);
failuresRef.current = 0;
}
} catch (err) {
if (!isCurrent()) return;
failuresRef.current += 1;
setDataKey(runResetKey);
setData(null);
hasDataRef.current = false;
setErrorKey(runResetKey);
setError(normalizePollingErrorMessage(err, stableT));
} finally {
if (inflightRef.current?.runId === runId) {
inflightRef.current = null;
}
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
if (
generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
&& loadingRunIdRef.current === runId
) {
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(false);
}
}
const queued = queuedRunRef.current;
if (
queued
&& generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
) {
queuedRunRef.current = null;
await run(queued.options);
queued.resolve();
}
}
}, [stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
if (!enabledRef.current || !pollRef.current) return;
const generation = generationRef.current;
timerRef.current = setTimeout(() => {
void run({ withLoading: false }).finally(() => {
if (generationRef.current === generation) {
scheduleNextPoll();
}
});
}, pollDelayMs());
}, [clearPollTimer, pollDelayMs, run]);
useEffect(() => {
const resetChanged = resetKeyRef.current !== resetKey;
resetKeyRef.current = resetKey;
generationRef.current += 1;
inflightRef.current = null;
clearPollTimer();
if (!enabled) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
return undefined;
}
if (resetChanged) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
}
const generation = generationRef.current;
void run({ withLoading: true }).finally(() => {
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
});
return () => {
generationRef.current += 1;
resolveQueuedRun();
loadingRunIdRef.current = 0;
inflightRef.current = null;
clearPollTimer();
};
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return {
data: dataKey === resetKey ? data : null,
error: errorKey === resetKey ? error : null,
loading: loadingKey === resetKey ? loading : enabled,
refresh,
};
}
+143
View File
@@ -0,0 +1,143 @@
import type React from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { retainStableAiPanelContexts } from '../domain/aiPanelContextsEqual';
import { collectSessionIds } from '../domain/workspace';
import type { TerminalContextReader } from '../domain/terminalContextRead';
import { detectLocalOs } from '../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../types';
import { buildAITerminalSessionInfo, type AIPanelContext } from '../components/terminalLayer/TerminalLayerSupport';
interface UseTerminalAiContextsOptions {
hosts: Host[];
hostsRef: React.MutableRefObject<Host[]>;
portForwardingRules: PortForwardingRule[];
portForwardingRulesRef: React.MutableRefObject<PortForwardingRule[]>;
mountedAiTabIds: string[];
sessionHostsMap: Map<string, Host>;
sessions: TerminalSession[];
sessionsRef: React.MutableRefObject<TerminalSession[]>;
terminalContextReadersRef: React.MutableRefObject<Map<string, TerminalContextReader>>;
workspaces: Workspace[];
workspacesRef: React.MutableRefObject<Workspace[]>;
}
export function useTerminalAiContexts({
hosts,
hostsRef,
portForwardingRules,
portForwardingRulesRef,
mountedAiTabIds,
sessionHostsMap,
sessions,
sessionsRef,
terminalContextReadersRef,
workspaces,
workspacesRef,
}: UseTerminalAiContextsOptions) {
const previousAiContextsRef = useRef<Map<string, AIPanelContext> | null>(null);
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const allHosts = hosts;
const tabIds = new Set<string>(mountedAiTabIds);
const contexts = new Map<string, AIPanelContext>();
for (const tabId of tabIds) {
const workspace = workspaceById.get(tabId);
if (workspace) {
const sessionIds = collectSessionIds(workspace.root);
contexts.set(tabId, {
scopeType: 'workspace',
scopeTargetId: workspace.id,
scopeHostIds: sessionIds
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
sessionHostsMap.get(sessionId),
localOs,
{ allHosts, portForwardingRules },
),
),
});
continue;
}
const session = sessionById.get(tabId);
if (!session) continue;
contexts.set(tabId, {
scopeType: 'terminal',
scopeTargetId: session.id,
scopeHostIds: session.hostId ? [session.hostId] : [],
scopeLabel: session.hostLabel ?? '',
terminalSessions: [
buildAITerminalSessionInfo(
session,
sessionHostsMap.get(session.id),
localOs,
{ allHosts, portForwardingRules },
),
],
});
}
const retained = retainStableAiPanelContexts(previousAiContextsRef.current, contexts);
previousAiContextsRef.current = retained;
return retained;
}, [sessions, workspaces, mountedAiTabIds, sessionHostsMap, hosts, portForwardingRules]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => {
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session ? sessionHostsMap.get(session.id) : undefined;
return buildAITerminalSessionInfo(session, host, localOs, {
allHosts: latestHosts,
portForwardingRules: portForwardingRulesRef.current,
});
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
readTerminalContext: (request: Parameters<TerminalContextReader>[0]) => {
const reader = terminalContextReadersRef.current.get(request.sessionId);
if (!reader) {
return Promise.resolve({
ok: false as const,
error: `Terminal session "${request.sessionId}" has no readable terminal buffer.`,
});
}
return reader(request);
},
};
}, [hostsRef, portForwardingRulesRef, sessionHostsMap, sessionsRef, terminalContextReadersRef, workspacesRef]);
return {
aiContextsByTabId,
resolveAIExecutorContext,
};
}
+713
View File
@@ -0,0 +1,713 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import {
readRememberedKeyPassphrases,
rememberImportedKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from "../defaultKeyPassphrases";
import {
countVaultImportDuplicates,
ensureVaultImportPersisted,
mergeVaultImportedGroups,
rebaseVaultImportedHosts,
resolveUniqueManagedImportGroupName,
rollbackVaultImportedHosts,
waitForVaultImportProgressPaint,
type VaultHostPersistenceResult,
type VaultImportProgress,
} from "./vaultImportProgress";
import { importVaultHostsInWorker } from "./vaultImportWorker";
import {
type VaultLockHandle,
withVaultImportLock,
} from "./vaultManagedImportLock";
/** Exit the import lock so a concurrent host save can finish, then retry. */
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
import { sanitizeHost } from "../domain/host";
import {
applyVaultImportDestination,
applyVaultHostImport,
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
type VaultImportFormat,
} from "../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../types";
import type { ImportOptions } from "../components/vault/ImportVaultDialog";
import { toast } from "../components/ui/toast";
interface UseVaultImportHandlersOptions {
customGroups: string[];
hosts: Host[];
keys: SSHKey[];
managedSources: ManagedSource[];
onReadPersistedHosts: () => Promise<Host[]>;
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onUpdateKeys: (keys: SSHKey[]) => void;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
setIsImportOpen: (open: boolean) => void;
t: (key: string, values?: Record<string, unknown>) => string;
}
export function useVaultImportHandlers({
customGroups,
hosts,
keys,
managedSources,
onReadPersistedHosts,
onUpdateHosts,
onUpdateKeys,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
setIsImportOpen,
t,
}: UseVaultImportHandlersOptions) {
const [importProgress, setImportProgress] = useState<VaultImportProgress | null>(null);
const customGroupsRef = useRef(customGroups);
const hostsRef = useRef(hosts);
const keysRef = useRef(keys);
const managedSourcesRef = useRef(managedSources);
const activeImportAbortRef = useRef<AbortController | null>(null);
const importCommitStartedRef = useRef(false);
const importInFlightRef = useRef(false);
customGroupsRef.current = customGroups;
hostsRef.current = hosts;
keysRef.current = keys;
managedSourcesRef.current = managedSources;
const resetImportProgress = useCallback(() => setImportProgress(null), []);
useEffect(() => () => {
if (!importCommitStartedRef.current) activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
}, []);
const cancelImport = useCallback(() => {
if (importCommitStartedRef.current) return;
activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
setImportProgress(null);
setIsImportOpen(false);
}, [setIsImportOpen]);
const handleImportFileSelected = useCallback(
async (format: VaultImportFormat, files: File[], options?: ImportOptions) => {
const file = files[0];
if (!file) return;
if (importInFlightRef.current) return;
importInFlightRef.current = true;
activeImportAbortRef.current?.abort();
const abortController = new AbortController();
activeImportAbortRef.current = abortController;
importCommitStartedRef.current = false;
const { signal } = abortController;
const throwIfCancelled = () => {
if (signal.aborted) {
throw new DOMException("Vault import cancelled.", "AbortError");
}
};
let rollbackSnapshot: {
baselineHosts: Host[];
appliedHosts: Host[];
} | null = null;
let rollbackPendingImport: () => Promise<void> = async () => undefined;
const relativeRoot = file.webkitRelativePath?.split(/[\\/]+/).filter(Boolean)[0];
const selectionName = files.length > 1 ? (relativeRoot || file.name) : file.name;
const formatLabel =
format === "putty"
? "PuTTY"
: format === "mobaxterm"
? "MobaXterm"
: format === "csv"
? "CSV"
: format === "securecrt"
? "SecureCRT"
: "ssh_config";
const updateProgress = (next: Partial<VaultImportProgress>) => {
setImportProgress((current) => ({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
totalFiles: files.length,
...current,
...next,
}));
};
setIsImportOpen(false);
setImportProgress({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
completedFiles: 0,
totalFiles: files.length,
});
try {
let result = await importVaultHostsInWorker({
format,
files,
encoding: options?.encoding,
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
},
});
throwIfCancelled();
const isManaged = format === "ssh_config" && options?.managed === true;
if (!isManaged) {
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
);
}
updateProgress({ stage: "preparing", percent: 70 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
updateProgress({ stage: "saving", percent: 85 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
const currentCustomGroups = customGroupsRef.current;
const currentHosts = hostsRef.current;
const currentManagedSources = managedSourcesRef.current;
const persistHosts = async (
nextHosts: Host[],
options?: {
baselineHosts?: Host[];
persistAttempt?: (
hosts: Host[],
baselineHosts: Host[],
) => Promise<VaultHostPersistenceResult>;
prepareAttempt?: (attempt: { baselineHosts: Host[]; appliedHosts: Host[] }) => Host[];
},
) => {
throwIfCancelled();
importCommitStartedRef.current = true;
let baselineHosts = options?.baselineHosts ?? currentHosts;
let appliedHosts = nextHosts;
while (true) {
appliedHosts = options?.prepareAttempt?.({ baselineHosts, appliedHosts }) ?? appliedHosts;
rollbackSnapshot = { baselineHosts, appliedHosts };
let persisted: VaultHostPersistenceResult;
if (options?.persistAttempt) {
persisted = await options.persistAttempt(appliedHosts, baselineHosts);
} else {
let hostUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
hostUpdate = onUpdateHosts(appliedHosts);
});
persisted = await hostUpdate!;
}
if (persisted !== "superseded") {
if (persisted !== false) rollbackSnapshot = null;
return persisted;
}
// Locked import commits must release the shared lock before retrying
// so a concurrent host save queued behind that lock can finish first.
if (options?.persistAttempt) {
throw RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT;
}
const latestHosts = hostsRef.current;
hostsRef.current = latestHosts;
appliedHosts = rebaseVaultImportedHosts({
currentHosts: latestHosts,
baselineHosts,
appliedHosts,
});
baselineHosts = latestHosts;
}
};
rollbackPendingImport = async () => {
const snapshot = rollbackSnapshot;
if (!snapshot) return;
while (true) {
const rollbackHosts = rollbackVaultImportedHosts({
currentHosts: hostsRef.current,
...snapshot,
});
let rollbackUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
rollbackUpdate = onUpdateHosts(rollbackHosts);
});
const persisted = await rollbackUpdate!;
if (persisted === "superseded") continue;
if (persisted === false) {
throw new Error(t("vault.import.progress.persistFailed"));
}
rollbackSnapshot = null;
return;
}
};
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
const requestedManagedGroup = options?.destination?.mode === "preserve"
? null
: options?.destination?.group ?? null;
let managedGroupName = requestedManagedGroup ?? `${fileBaseName} - Managed`;
// Check if this file is already managed
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
if (isManaged && !filePath) {
// Cannot proceed with managed import without a valid file path
const message = t("vault.import.sshConfig.noFilePathDesc");
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.sshConfig.noFilePath"),
);
return;
}
if (isManaged) {
const existingSource = currentManagedSources.find(s => s.filePath === filePath);
if (existingSource) {
const message = t("vault.import.sshConfig.alreadyManagedDesc", {
group: existingSource.groupName,
});
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.sshConfig.alreadyManaged"),
);
return;
}
}
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
// group as part of session identity so direct vs proxy copies can coexist.
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
// For managed imports, also update existing hosts to be managed
let updatedExistingHosts: Host[] = [];
if (isManaged) {
const importedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = currentHosts.filter((h) => importedKeys.has(makeKey(h)));
}
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const latestPersistedSources = onReadPersistedManagedSources();
managedSourcesRef.current = latestPersistedSources;
const sourceClaim = latestPersistedSources.find((source) => source.filePath === filePath);
if (sourceClaim) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: sourceClaim.groupName,
}));
}
const managedBaselineHosts = await onReadPersistedHosts();
hostsRef.current = managedBaselineHosts;
const managedExistingKeys = new Set(managedBaselineHosts.map(makeKey));
newHosts = result.hosts.filter((host) => !managedExistingKeys.has(makeKey(host)));
const managedImportedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = managedBaselineHosts.filter((host) => (
!host.managedSourceId && managedImportedKeys.has(makeKey(host))
));
if (newHosts.length === 0 && updatedExistingHosts.length === 0) return;
const sourceId = crypto.randomUUID();
let newSource: ManagedSource = {
id: sourceId,
type: "ssh_config",
filePath: filePath,
groupName: managedGroupName,
lastSyncedAt: Date.now(),
};
newHosts = newHosts.map((h) => ({
...h,
group: managedGroupName,
// Only SSH hosts can be managed (SSH config only supports SSH)
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
}));
// Update existing hosts to be managed (move to managed group)
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
const updatedHosts = managedBaselineHosts.map((h) => {
if (!existingHostIds.has(h.id)) return h;
const canBeManaged = !h.protocol || h.protocol === "ssh";
return {
...h,
group: managedGroupName,
managedSourceId: canBeManaged ? sourceId : undefined,
// Sanitize label for managed hosts
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
};
});
let nextGroups: string[] = [];
const ensureManagedSourceStillAvailable = () => {
const conflictingSource = managedSourcesRef.current.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
};
const prepareManagedAttempt = ({
baselineHosts,
appliedHosts,
}: {
baselineHosts: Host[];
appliedHosts: Host[];
}) => {
ensureManagedSourceStillAvailable();
managedGroupName = requestedManagedGroup ?? resolveUniqueManagedImportGroupName({
baseName: fileBaseName,
customGroups: customGroupsRef.current,
hosts: baselineHosts,
managedSources: managedSourcesRef.current,
ownerSourceId: sourceId,
});
newSource = { ...newSource, groupName: managedGroupName };
nextGroups = Array.from(new Set([
...currentCustomGroups,
...(requestedManagedGroup ? [] : result.groups),
managedGroupName,
]));
return appliedHosts.map((host) => (
host.managedSourceId === sourceId
? { ...host, group: managedGroupName }
: host
));
};
const hostPersisted = await persistHosts(
[...updatedHosts, ...newHosts].map((host: Host) => sanitizeHost(host)),
{
baselineHosts: managedBaselineHosts,
prepareAttempt: prepareManagedAttempt,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: currentCustomGroups,
appliedGroups: nextGroups,
}),
(latestPersistedSources) => {
const conflictingSource = latestPersistedSources.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
return [
...latestPersistedSources.filter((source) => source.id !== newSource.id),
newSource,
];
},
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
},
);
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
// Drain concurrent locked writes outside the critical section.
hostsRef.current = await onReadPersistedHosts();
}
}
} else if (newHosts.length > 0) {
let addedHostIds = new Set<string>();
let addedHostKeyPaths = new Map<string, string>();
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const importBaselineHosts = await onReadPersistedHosts();
hostsRef.current = importBaselineHosts;
const importBaselineGroups = customGroupsRef.current;
const merged = applyVaultHostImport(
importBaselineHosts,
importBaselineGroups,
result,
{ skipDuplicates: format !== "securecrt" },
);
newHosts = merged.addedHosts;
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
return keyPath ? [[host.id, keyPath] as const] : [];
}));
if (newHosts.length === 0) return;
const hostPersisted = await persistHosts(merged.hosts, {
baselineHosts: importBaselineHosts,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: importBaselineGroups,
appliedGroups: merged.customGroups,
}),
(latestPersistedSources) => latestPersistedSources,
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
});
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
hostsRef.current = await onReadPersistedHosts();
}
}
throwIfCancelled();
const resolved = await resolveVaultImportKeyPassphraseConflicts(
result.keyPassphraseCandidates ?? result.keyPassphrases ?? [],
resolveDefaultKeyPassphraseAliases,
addedHostIds,
addedHostKeyPaths,
);
throwIfCancelled();
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
resolved.keyPassphrases,
(keyPath) => readRememberedKeyPassphrases(keyPath, keysRef.current),
);
throwIfCancelled();
result.issues = mergeVaultImportIssues(
result.issues,
resolved.issues,
checked.issues,
);
for (const entry of checked.keyPassphrases) {
try {
const saved = await rememberImportedKeyPassphrase({
keyPath: entry.keyPath,
passphrase: entry.passphrase,
keys: keysRef.current,
getKeys: () => keysRef.current,
updateKeys: onUpdateKeys,
setCurrentKeys: (updatedKeys) => {
keysRef.current = updatedKeys;
},
});
throwIfCancelled();
if (saved === "conflict") {
result.issues.push({
level: "warning",
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
});
} else if (saved === "unreadable") {
result.issues.push({
level: "warning",
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
});
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
result.issues.push({
level: "warning",
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
});
}
}
result.issues = mergeVaultImportIssues(result.issues);
}
// Count total hosts affected (new + converted to managed)
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
const skipped = result.stats.skipped;
const duplicates = countVaultImportDuplicates({
importedHostCount: result.hosts.length,
newHostCount: newHosts.length,
fileDuplicateCount: result.stats.duplicates,
managed: isManaged,
});
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
if (result.stats.parsed === 0 && totalAffected === 0) {
const message = t("vault.import.toast.noEntries", { format: formatLabel });
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.toast.failedTitle"),
);
return;
}
if (totalAffected === 0) {
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: 0,
skipped,
duplicates,
});
toast.warning(
t("vault.import.toast.noNewHosts", { format: formatLabel }),
t("vault.import.toast.completedTitle"),
);
return;
}
throwIfCancelled();
rollbackSnapshot = null;
if (isManaged) {
toast.success(
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
t("vault.import.toast.completedTitle"),
);
} else {
const details = t("vault.import.toast.summary", {
count: totalAffected,
skipped,
duplicates,
});
if (hasWarnings) {
const firstIssue = result.issues[0]?.message;
toast.warning(
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
t("vault.import.toast.completedTitle"),
);
} else {
toast.success(details, t("vault.import.toast.completedTitle"));
}
}
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: totalAffected,
skipped,
duplicates,
});
} catch (err) {
let rollbackFailure: unknown;
if (rollbackSnapshot) {
try {
await rollbackPendingImport();
} catch (rollbackError) {
rollbackFailure = rollbackError;
console.error("[vault import] Failed to rollback imported hosts.", rollbackError);
}
}
if (
!rollbackFailure
&& (
signal.aborted
|| (err instanceof DOMException && err.name === "AbortError")
)
) return;
const originalMessage =
err instanceof Error ? err.message : t("common.unknownError");
const message = rollbackFailure
? `${originalMessage} ${t("vault.import.progress.rollbackFailed")}`
: originalMessage;
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(message, t("vault.import.toast.failedTitle"));
} finally {
if (activeImportAbortRef.current === abortController) {
activeImportAbortRef.current = null;
}
importCommitStartedRef.current = false;
importInFlightRef.current = false;
}
},
[
onReadPersistedHosts,
onCommitVaultImportTransaction,
onReadPersistedManagedSources,
onUpdateHosts,
onUpdateKeys,
setIsImportOpen,
t,
],
);
return {
cancelImport,
handleImportFileSelected,
importProgress,
resetImportProgress,
};
}
+2 -2
View File
@@ -49,8 +49,8 @@ interface AIChatPanelContentProps {
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
messages: ChatMessage[];
isStreaming: boolean;
activeCompaction?: import('./ai/hooks/useAgentCompactionUi').ActiveCompactionUi | null;
contextUsage?: import('./ai/hooks/useAgentCompactionUi').AgentContextUsage | null;
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
+2 -2
View File
@@ -49,7 +49,7 @@ import {
getNetcattyBridge,
isAIChatSessionStreaming,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
@@ -60,7 +60,7 @@ import { stopAgentTurn } from '../infrastructure/ai/harness/agentStop';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIPermissionGrantsState } from '../application/state/useAIPermissionGrantsState';
import { useConversationExport } from './ai/hooks/useConversationExport';
import { useAgentContextUsage } from './ai/hooks/useAgentCompactionUi';
import { useAgentContextUsage } from '../application/state/useAgentCompactionUi';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import {
buildCursorListModelsAgentEnv,
+1 -1
View File
@@ -61,7 +61,7 @@ import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { sftpFocusStore } from "../application/state/sftp/sftpFocusStore";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
import {
+1 -1
View File
@@ -44,7 +44,7 @@ import { SftpContextProvider, activeTabStore } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
+1 -1
View File
@@ -147,7 +147,7 @@ import { Badge } from "./ui/badge";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { VaultViewLayout } from "./vault/VaultViewLayout";
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
import { useVaultImportHandlers } from "./vault/useVaultImportHandlers";
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
+1 -1
View File
@@ -37,7 +37,7 @@ import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile }
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import type { AgentContextUsage } from './hooks/useAgentCompactionUi';
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
import {
CHAT_INPUT_DEFAULT_HEIGHT,
CHAT_INPUT_MAX_HEIGHT,
+1 -1
View File
@@ -67,7 +67,7 @@ import {
compactionStatusText,
resolveCompactionStatusText,
type ActiveCompactionUi,
} from './hooks/useAgentCompactionUi';
} from '../../application/state/useAgentCompactionUi';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
+6 -266
View File
@@ -1,268 +1,8 @@
/**
* useAIChatStreaming — React UI layer for AI chat streaming.
*
* Turn orchestration lives in AgentRuntime + TurnDrivers; this hook only
* manages streaming state, abort controllers, and UI callbacks.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
AIPermissionMode,
AIToolIntegrationMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
import type {
TurnSteerInput,
TurnSteerResult,
} from '../../../infrastructure/ai/harness/turnDrivers/types';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
import {
generateId,
/** @deprecated Import from `@/application/state/useAIChatStreaming` instead. */
export {
useAIChatStreaming,
isAIChatSessionStreaming,
getNetcattyBridge,
type ActiveCompactionUi,
type DefaultTargetSessionHint,
type TerminalSessionInfo,
} from './aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
export { getNetcattyBridge } from './aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
/** Whether a chat session still has an active stream (used to keep panel mounted while hidden). */
export function isAIChatSessionStreaming(sessionId: string | null | undefined): boolean {
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
export interface UseAIChatStreamingParams {
maxIterations: number;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
persistContextCompaction?: (
sessionId: string,
compaction: import('../../../infrastructure/ai/types').AISessionContextCompaction,
) => void;
}
export interface UseAIChatStreamingReturn {
streamingSessionIds: Set<string>;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
sendToCattyAgent: (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
sendToExternalAgent: (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => Promise<void>;
steerExternalAgent: (input: TurnSteerInput) => Promise<TurnSteerResult>;
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
activeCompaction: import('./useAgentCompactionUi').ActiveCompactionUi | null;
}
export interface SendToCattyContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
commandTimeout?: number;
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
titleText?: string;
selectedUserSkillSlugs?: string[];
permissionMode?: AIPermissionMode;
forceCompaction?: boolean;
}
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
defaultTargetSession?: DefaultTargetSessionHint;
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
permissionMode: AIPermissionMode;
}
export function useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
const activeCompaction = useAgentCompactionUi();
const reportStreamError = useCallback((
sessionId: string,
abortSignal: AbortSignal,
err: unknown,
) => {
if (abortSignal.aborted) return;
console.error('[AIChatSidePanel] Stream error (full):', err);
const errorInfo = classifyError(err);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo,
timestamp: Date.now(),
});
}, [updateLastMessage, addMessageToSession]);
const uiCallbacks = useCallback(() => ({
addMessageToSession,
updateLastMessage,
updateMessageById,
reportStreamError,
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
assistantMsgId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string; filePath?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
await getAgentRuntime().runTurn({
backend: 'external-sdk',
chatSessionId: sessionId,
assistantMsgId,
userText: trimmed,
signal: abortController.signal,
agentConfig,
attachedImages,
context,
bridge,
ui: uiCallbacks(),
});
}, [uiCallbacks]);
const steerExternalAgent = useCallback(async (input: TurnSteerInput) => {
return getAgentRuntime().steerTurn(input);
}, []);
const sendToCattyAgent = useCallback(async (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
try {
await getAgentRuntime().runTurn({
backend: 'catty',
chatSessionId: sessionId,
sendScopeKey,
userText: trimmed,
signal: abortController.signal,
currentSession,
assistantMsgId,
context,
attachments,
maxIterations,
bridge,
ui: uiCallbacks(),
});
} finally {
abortControllersRef.current.delete(sessionId);
}
}, [maxIterations, uiCallbacks]);
return {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
sendToCattyAgent,
sendToExternalAgent,
steerExternalAgent,
reportStreamError,
activeCompaction,
};
}
} from "../../../application/state/useAIChatStreaming";
+9 -95
View File
@@ -1,95 +1,9 @@
import { useEffect, useState } from 'react';
import type { ContextPrepareTrigger } from '../../../infrastructure/ai/harness/types';
import { getAgentRuntime } from '../../../infrastructure/ai/harness/globalAgentRuntime';
import { CATTY_COMPACTION_STATUS_KEYS } from '../../../infrastructure/ai/harness/compactionStatusKeys';
export interface ActiveCompactionUi {
sessionId: string;
trigger: ContextPrepareTrigger;
}
export interface AgentContextUsage {
sessionId: string;
inputTokens: number;
contextWindow: number;
estimated: boolean;
}
function statusKeyForTrigger(trigger: ContextPrepareTrigger): string {
switch (trigger) {
case 'step':
return CATTY_COMPACTION_STATUS_KEYS.step;
case '413-retry':
return CATTY_COMPACTION_STATUS_KEYS.retry;
default:
return CATTY_COMPACTION_STATUS_KEYS.preTurn;
}
}
export function useAgentCompactionUi(): ActiveCompactionUi | null {
const [active, setActive] = useState<ActiveCompactionUi | null>(null);
useEffect(() => {
const unsubscribe = getAgentRuntime().subscribe((event) => {
const sessionId = event.chatSessionId ?? event.sessionId;
if (event.type === 'compaction_start') {
setActive({ sessionId, trigger: event.trigger });
return;
}
if (event.type === 'compaction' || event.type === 'turn_end') {
setActive((prev) => (prev?.sessionId === sessionId ? null : prev));
}
});
return unsubscribe;
}, []);
return active;
}
export function compactionStatusText(
trigger: ContextPrepareTrigger,
translate: (key: string, params?: Record<string, string | number>) => string,
): string {
return translate(statusKeyForTrigger(trigger));
}
export function resolveCompactionStatusText(
statusText: string | undefined,
translate: (key: string) => string,
): string | undefined {
if (!statusText) return undefined;
if (statusText.startsWith('ai.')) return translate(statusText);
return statusText;
}
export function useAgentContextUsage(sessionId: string | null | undefined): AgentContextUsage | null {
const [usage, setUsage] = useState<AgentContextUsage | null>(null);
useEffect(() => {
if (!sessionId) {
setUsage(null);
return undefined;
}
const unsubscribe = getAgentRuntime().subscribe((event) => {
const eventSessionId = event.chatSessionId ?? event.sessionId;
if (eventSessionId !== sessionId || event.type !== 'context_snapshot') return;
const snapshot = event.snapshot;
if (
typeof snapshot.contextWindow !== 'number'
|| typeof snapshot.estimatedInputTokens !== 'number'
) {
return;
}
setUsage({
sessionId,
inputTokens: Math.max(0, snapshot.estimatedInputTokens),
contextWindow: Math.max(1, snapshot.contextWindow),
estimated: true,
});
});
return unsubscribe;
}, [sessionId]);
return usage?.sessionId === sessionId ? usage : null;
}
/** @deprecated Import from `@/application/state/useAgentCompactionUi` instead. */
export {
useAgentCompactionUi,
useAgentContextUsage,
compactionStatusText,
resolveCompactionStatusText,
type ActiveCompactionUi,
type AgentContextUsage,
} from "../../../application/state/useAgentCompactionUi";
+1 -1
View File
@@ -14,7 +14,7 @@ import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import type { UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
import type { UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { buildSftpColumnTemplate, isNavigableDirectory, isSftpColumnMenuKey } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./SftpFileRow";
+1 -1
View File
@@ -12,7 +12,7 @@ import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions }
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
import type { SftpTransferSource } from './SftpContext';
import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from '../../application/state/sftp/sftpTreeSelectionStore';
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SftpColumnMenuItems } from './SftpColumnMenuItems';
+1 -1
View File
@@ -1,7 +1,7 @@
import type { SftpFileEntry } from '../../types';
import type { SftpPane } from '../../application/state/sftp/types';
import type { SftpTransferSource } from './SftpContext';
import type { UseSftpPaneSortingResult } from './hooks/useSftpPaneSorting';
import type { UseSftpPaneSortingResult } from '../../application/state/sftp/useSftpPaneSorting';
export interface SftpPaneTreeViewProps {
pane: SftpPane;
+5 -5
View File
@@ -25,16 +25,16 @@ import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "./hooks/useSftpPaneSorting";
import { useSftpPaneSorting, type UseSftpPaneSortingResult } from "../../application/state/sftp/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { sftpPaneViewModeStore } from "../../application/state/sftp/sftpPaneViewModeStore";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpDialogActionHandler } from "../../application/state/sftp/sftpDialogActionStore";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
import { useLocalSftpBookmarks } from "../../application/state/sftp/localSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
import { useSftpHostViewMode } from "../../application/state/sftp/sftpHostViewModeStore";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
import { sftpClipboardUploadStore } from "./clipboardUpload";
interface TreeReloadRequest {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
+8 -65
View File
@@ -1,65 +1,8 @@
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 ──
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
}
// ── Hook ──
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
setBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};
/** @deprecated Import from `@/application/state/sftp/localSftpBookmarks` instead. */
export {
useLocalSftpBookmarks,
subscribeLocalSftpBookmarks,
getLocalSftpBookmarksSnapshot,
rehydrateLocalSftpBookmarks,
setLocalSftpBookmarks,
} from "../../../application/state/sftp/localSftpBookmarks";
+6 -124
View File
@@ -1,124 +1,6 @@
/**
* SFTP Clipboard Store
*
* Manages clipboard state for SFTP file operations (copy/cut/paste)
* This is a simple store that holds the clipboard state and operation type.
*/
import { useSyncExternalStore } from "react";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;
sourceSide: "left" | "right";
operation: SftpClipboardOperation;
}
type ClipboardListener = () => void;
let clipboardState: SftpClipboardState | null = null;
const clipboardListeners = new Set<ClipboardListener>();
const notifyListeners = () => {
clipboardListeners.forEach((listener) => listener());
};
export const sftpClipboardStore = {
getSnapshot: (): SftpClipboardState | null => clipboardState,
subscribe: (listener: ClipboardListener) => {
clipboardListeners.add(listener);
return () => clipboardListeners.delete(listener);
},
/**
* Copy files to clipboard
*/
copy: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "copy",
};
notifyListeners();
},
/**
* Cut files to clipboard
*/
cut: (
files: SftpClipboardFile[],
sourcePath: string,
sourceConnectionId: string,
sourceSide: "left" | "right"
) => {
clipboardState = {
files,
sourcePath,
sourceConnectionId,
sourceSide,
operation: "cut",
};
notifyListeners();
},
/**
* Clear clipboard (called after paste for cut operation)
*/
clear: () => {
clipboardState = null;
notifyListeners();
},
/**
* Update clipboard file list (used for partial cut transfers)
*/
updateFiles: (files: SftpClipboardFile[]) => {
if (!clipboardState) return;
if (files.length === 0) {
clipboardState = null;
} else {
clipboardState = {
...clipboardState,
files,
};
}
notifyListeners();
},
/**
* Check if there are files in the clipboard
*/
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
/**
* Get the clipboard state
*/
get: (): SftpClipboardState | null => clipboardState,
};
/**
* React hook to subscribe to clipboard state changes
*/
export const useSftpClipboard = (): SftpClipboardState | null => {
return useSyncExternalStore(
sftpClipboardStore.subscribe,
sftpClipboardStore.getSnapshot,
sftpClipboardStore.getSnapshot
);
};
/** @deprecated Import from `@/application/state/sftp/sftpClipboardStore` instead. */
export {
sftpClipboardStore,
useSftpClipboard,
type SftpClipboardFile,
} from "../../../application/state/sftp/sftpClipboardStore";
+6 -124
View File
@@ -1,124 +1,6 @@
/**
* SFTP Dialog Action Store
*
* Manages dialog action triggers for SFTP operations.
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
*/
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
type ActionListener = () => void;
let dialogAction: SftpDialogAction | null = null;
const actionListeners = new Set<ActionListener>();
const notifyListeners = () => {
actionListeners.forEach((listener) => listener());
};
export const sftpDialogActionStore = {
getSnapshot: (): SftpDialogAction | null => dialogAction,
subscribe: (listener: ActionListener) => {
actionListeners.add(listener);
return () => actionListeners.delete(listener);
},
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
}
notifyListeners();
},
/**
* Clear the current action (called after a pane handles it)
*/
clear: () => {
dialogAction = null;
notifyListeners();
},
/**
* Get the current action
*/
get: (): SftpDialogAction | null => dialogAction,
};
/**
* React hook to subscribe to dialog action changes
*/
export const useSftpDialogAction = (): SftpDialogAction | null => {
return useSyncExternalStore(
sftpDialogActionStore.subscribe,
sftpDialogActionStore.getSnapshot,
sftpDialogActionStore.getSnapshot
);
};
/**
* React hook for a pane to respond to dialog actions
* Only the pane matching the targetSide will execute the callback
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
case "rename":
if (handlers.onRename && action.targetFiles?.[0]) {
handlers.onRename(action.targetFiles[0]);
}
break;
case "delete":
if (handlers.onDelete && action.targetFiles) {
handlers.onDelete(action.targetFiles);
}
break;
case "newFolder":
handlers.onNewFolder?.();
break;
case "newFile":
handlers.onNewFile?.();
break;
}
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, scopeId, handlers, isActive]);
};
/** @deprecated Import from `@/application/state/sftp/sftpDialogActionStore` instead. */
export {
sftpDialogActionStore,
useSftpDialogAction,
useSftpDialogActionHandler,
} from "../../../application/state/sftp/sftpDialogActionStore";
+6 -54
View File
@@ -1,54 +1,6 @@
/**
* SFTP Focused Pane Store
*
* Tracks which SFTP pane (left or right) is currently focused.
* This is used to determine which pane should receive keyboard shortcut actions.
*/
import { useSyncExternalStore } from "react";
export type SftpFocusedSide = "left" | "right";
type FocusListener = () => void;
let focusedSide: SftpFocusedSide = "left";
const focusListeners = new Set<FocusListener>();
const notifyListeners = () => {
focusListeners.forEach((listener) => listener());
};
export const sftpFocusStore = {
getSnapshot: (): SftpFocusedSide => focusedSide,
subscribe: (listener: FocusListener) => {
focusListeners.add(listener);
return () => focusListeners.delete(listener);
},
/**
* Set the focused side
*/
setFocusedSide: (side: SftpFocusedSide) => {
if (focusedSide !== side) {
focusedSide = side;
notifyListeners();
}
},
/**
* Get the current focused side
*/
getFocusedSide: (): SftpFocusedSide => focusedSide,
};
/**
* React hook to subscribe to focused side changes
*/
export const useSftpFocusedSide = (): SftpFocusedSide => {
return useSyncExternalStore(
sftpFocusStore.subscribe,
sftpFocusStore.getSnapshot,
sftpFocusStore.getSnapshot
);
};
/** @deprecated Import from `@/application/state/sftp/sftpFocusStore` instead. */
export {
sftpFocusStore,
useSftpFocusedSide,
type SftpFocusedSide,
} from "../../../application/state/sftp/sftpFocusStore";
+6 -70
View File
@@ -1,70 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}
/** @deprecated Import from `@/application/state/sftp/sftpHostViewModeStore` instead. */
export {
getHostViewMode,
setHostViewMode,
useSftpHostViewMode,
} from "../../../application/state/sftp/sftpHostViewModeStore";
@@ -10,10 +10,10 @@ import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { getParentPath, joinPath, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpClipboardStore, SftpClipboardFile } from "../../../application/state/sftp/sftpClipboardStore";
import { sftpFocusStore } from "../../../application/state/sftp/sftpFocusStore";
import { sftpDialogActionStore } from "../../../application/state/sftp/sftpDialogActionStore";
import { sftpTreeSelectionStore } from "../../../application/state/sftp/sftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { sftpPaneViewModeStore } from "../../../application/state/sftp/sftpPaneViewModeStore";
import { keepOnlyPaneSelections } from "./selectionScope";
+5 -168
View File
@@ -1,168 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
DEFAULT_SFTP_COLUMN_VISIBILITY,
normalizeSftpColumnVisibility,
type ColumnWidths,
type SftpColumnVisibility,
type SortField,
type SortOrder,
} from "../utils";
import { STORAGE_KEY_SFTP_VISIBLE_COLUMNS } from "../../../infrastructure/config/storageKeys";
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from "../../../infrastructure/persistence/localStorageAdapter";
import { useSftpDirectoriesFirst } from "../../../application/state/sftp/useSftpDirectoriesFirst";
export interface UseSftpPaneSortingResult {
sortField: SortField;
sortOrder: SortOrder;
directoriesFirst: boolean;
columnWidths: ColumnWidths;
visibleColumns: SftpColumnVisibility;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
toggleColumnVisibility: (field: keyof ColumnWidths) => void;
toggleDirectoriesFirst: () => void;
}
export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const { directoriesFirst, toggleDirectoriesFirst } = useSftpDirectoriesFirst();
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 56,
modified: 28,
size: 7,
type: 9,
});
const [visibleColumns, setVisibleColumns] = useState<SftpColumnVisibility>(() =>
normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
),
);
useEffect(() => {
const syncVisibleColumns = (event: Event) => {
if (event instanceof StorageEvent && event.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
if (event instanceof CustomEvent && event.detail?.key !== STORAGE_KEY_SFTP_VISIBLE_COLUMNS) return;
const next = normalizeSftpColumnVisibility(
localStorageAdapter.read<Partial<SftpColumnVisibility>>(STORAGE_KEY_SFTP_VISIBLE_COLUMNS),
);
setVisibleColumns(next);
};
window.addEventListener("storage", syncVisibleColumns);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
return () => {
window.removeEventListener("storage", syncVisibleColumns);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, syncVisibleColumns);
};
}, []);
useEffect(() => {
if (visibleColumns[sortField]) return;
setSortField("name");
setSortOrder("asc");
}, [sortField, visibleColumns]);
const toggleColumnVisibility = useCallback((field: keyof ColumnWidths) => {
if (field === "name") return;
setVisibleColumns((current) => {
const next = {
...DEFAULT_SFTP_COLUMN_VISIBILITY,
...current,
name: true,
[field]: !current[field],
};
localStorageAdapter.write(STORAGE_KEY_SFTP_VISIBLE_COLUMNS, next);
return next;
});
}, []);
const resizingRef = useRef<{
field: keyof ColumnWidths;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
}, [sortField]);
const rafIdRef = useRef<number | null>(null);
const lastClientXRef = useRef(0);
const applyColumnWidth = useCallback(() => {
if (!resizingRef.current) return;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
name: { min: 36, max: 78 },
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
min,
Math.min(max, startWidth + diff / 8),
);
setColumnWidths((prev) => ({
...prev,
[field]: newWidth,
}));
}, []);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
lastClientXRef.current = e.clientX;
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
applyColumnWidth();
});
}, [applyColumnWidth]);
const handleResizeEnd = useCallback(() => {
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
applyColumnWidth();
rafIdRef.current = null;
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [applyColumnWidth, handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
e: React.MouseEvent,
) => {
e.preventDefault();
e.stopPropagation();
lastClientXRef.current = e.clientX;
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
directoriesFirst,
columnWidths,
visibleColumns,
handleSort,
handleResizeStart,
toggleColumnVisibility,
toggleDirectoriesFirst,
};
};
/** @deprecated Import from `@/application/state/sftp/useSftpPaneSorting` instead. */
export {
useSftpPaneSorting,
type UseSftpPaneSortingResult,
} from "../../../application/state/sftp/useSftpPaneSorting";
@@ -1,153 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};
/** @deprecated Import from `@/application/state/sftp/sftpTreeSelectionStore` instead. */
export {
sftpTreeSelectionStore,
useSftpTreeSelectionState,
type SftpTreeSelectionItem,
} from "../../../application/state/sftp/sftpTreeSelectionStore";
@@ -27,8 +27,8 @@ import {
SystemPanelToolbar,
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
@@ -23,8 +23,8 @@ import {
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
+1 -1
View File
@@ -20,7 +20,7 @@ import {
SystemPanelStatusBadge,
SystemPanelToolbar,
} from './SystemPanelUi';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
type Backend = ReturnType<typeof useSystemManagerBackend>;
@@ -33,7 +33,7 @@ import {
} from './SystemPanelUi';
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import {
getCachedProcessList,
setCachedProcessList,
@@ -18,7 +18,7 @@ import { TmuxManagerTab } from './TmuxManagerTab';
import { WorkspaceSidebarHostHeader } from '../terminalLayer/WorkspaceSidebarHostHeader';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
import { SystemPanelEmpty, SystemPanelShell } from './SystemPanelUi';
import { useSessionCapabilities } from './hooks/useSystemManager';
import { useSessionCapabilities } from '../../application/state/useSystemManager';
const SystemPanelChecking = memo(function SystemPanelChecking({
message,
@@ -10,7 +10,7 @@ import React, { memo, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { aggregateMountedDiskUsage } from '../../domain/systemDiskUsage';
import { cn } from '../../lib/utils';
import { useServerStats } from '../terminal/hooks/useServerStats';
import { useServerStats } from '../../application/state/useServerStats';
import { ResourceBar } from './ResourceBar';
import {
SystemPanelEmpty,
+2 -2
View File
@@ -17,8 +17,8 @@ import {
SystemPanelShell,
SystemPanelToolbar,
} from './SystemPanelUi';
import { useAsyncRecordCache } from './hooks/useAsyncRecordCache';
import { usePolling, useStableTranslate } from './hooks/useSystemManager';
import { useAsyncRecordCache } from '../../application/state/systemManager/useAsyncRecordCache';
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
import { TmuxNewSessionModal } from './TmuxNewSessionModal';
import { TmuxSessionCard } from './TmuxSessionCard';
import { useStableListOrder, mergePollListByKey } from './listStable';
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
TmuxSessionInfo,
} from '../../domain/systemManager/types';
import type { TerminalSession } from '../../types';
import type { AsyncRecordState } from './hooks/useAsyncRecordCache';
import type { AsyncRecordState } from '../../application/state/systemManager/useAsyncRecordCache';
import type { TmuxSessionDetails } from './TmuxManagerTab';
import {
SystemPanelCollapsible,
@@ -1,269 +1,5 @@
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
export interface AsyncRecordState<T> {
data: T | null;
loading: boolean;
error: string | null;
updatedAt: number | null;
}
type RecordMap<T> = Record<string, AsyncRecordState<T>>;
interface UseAsyncRecordCacheOptions<TItem, TValue> {
items: TItem[];
enabled: boolean;
getKey: (item: TItem) => string;
fetchRecord: (item: TItem) => Promise<TValue | null>;
prefetchLimit?: number;
prefetchDelayMs?: number;
staleTimeMs?: number;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function scheduleIdleTask(callback: () => void): () => void {
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(callback, { timeout: 1200 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(callback, 80);
return () => window.clearTimeout(id);
}
function normalizeRecordError(error: unknown): string {
return error instanceof Error ? error.message : String(error || 'Unknown error');
}
function isRecordFresh<TValue>(record: AsyncRecordState<TValue> | undefined, staleTimeMs: number): boolean {
if (!record || record.error || record.updatedAt === null) return false;
if (!Number.isFinite(staleTimeMs)) return true;
return Date.now() - record.updatedAt < staleTimeMs;
}
const EMPTY_RECORDS = {};
export function useAsyncRecordCache<TItem, TValue>({
items,
enabled,
getKey,
fetchRecord,
prefetchLimit = 64,
prefetchDelayMs = 16,
staleTimeMs = 30_000,
}: UseAsyncRecordCacheOptions<TItem, TValue>) {
const [records, setRecords] = useState<RecordMap<TValue>>(() => EMPTY_RECORDS);
const recordsRef = useRef<RecordMap<TValue>>(records);
const enabledRef = useRef(enabled);
const inflightRef = useRef(new Set<string>());
const requestVersionRef = useRef(new Map<string, number>());
const queuedForceRef = useRef(new Set<string>());
const loadRecordRef = useRef<(
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => Promise<void>>(async () => {});
recordsRef.current = records;
enabledRef.current = enabled;
const commitRecords = useCallback((
updater: (prev: RecordMap<TValue>) => RecordMap<TValue>,
urgent = false,
) => {
const apply = () => {
setRecords((prev) => {
const next = updater(prev);
recordsRef.current = next;
return next;
});
};
if (urgent) {
apply();
return;
}
startTransition(apply);
}, []);
const loadRecord = useCallback(async (
item: TItem,
options?: { force?: boolean; urgent?: boolean },
) => {
if (!enabledRef.current) return;
const key = getKey(item);
if (!key) return;
if (inflightRef.current.has(key)) {
if (options?.force) {
queuedForceRef.current.add(key);
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
}
return;
}
const existing = recordsRef.current[key];
if (!options?.force && isRecordFresh(existing, staleTimeMs)) {
return;
}
const requestVersion = (requestVersionRef.current.get(key) ?? 0) + 1;
requestVersionRef.current.set(key, requestVersion);
inflightRef.current.add(key);
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: true,
error: null,
updatedAt: prev[key]?.updatedAt ?? null,
},
}), options?.urgent);
try {
const data = await fetchRecord(item);
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data,
loading: false,
error: null,
updatedAt: Date.now(),
},
}));
} catch (error) {
if (requestVersionRef.current.get(key) !== requestVersion) return;
commitRecords((prev) => ({
...prev,
[key]: {
data: prev[key]?.data ?? null,
loading: false,
error: normalizeRecordError(error),
updatedAt: prev[key]?.updatedAt ?? null,
},
}));
} finally {
inflightRef.current.delete(key);
if (queuedForceRef.current.has(key)) {
if (enabledRef.current) {
queuedForceRef.current.delete(key);
void loadRecordRef.current(item, { force: true, urgent: options?.urgent });
} else {
commitRecords((prev) => {
const current = prev[key];
if (!current?.loading) return prev;
return {
...prev,
[key]: {
...current,
loading: false,
},
};
}, true);
}
}
}
}, [commitRecords, fetchRecord, getKey, staleTimeMs]);
loadRecordRef.current = loadRecord;
useEffect(() => {
const itemKeys = new Set(items.map(getKey).filter(Boolean));
for (const key of queuedForceRef.current) {
if (!itemKeys.has(key)) {
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (!itemKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
});
}, [commitRecords, getKey, items]);
useEffect(() => {
if (!enabled || queuedForceRef.current.size === 0) return;
for (const item of items) {
const key = getKey(item);
if (!key || !queuedForceRef.current.has(key)) continue;
queuedForceRef.current.delete(key);
void loadRecord(item, { force: true, urgent: true });
}
}, [enabled, getKey, items, loadRecord]);
useEffect(() => {
if (!enabled || items.length === 0 || prefetchLimit <= 0) return undefined;
let cancelled = false;
const candidates = items.slice(0, prefetchLimit);
const cancelIdleTask = scheduleIdleTask(() => {
void (async () => {
for (const item of candidates) {
if (cancelled) return;
await loadRecord(item);
if (prefetchDelayMs > 0) {
await delay(prefetchDelayMs);
}
}
})();
});
return () => {
cancelled = true;
cancelIdleTask();
};
}, [enabled, items, loadRecord, prefetchDelayMs, prefetchLimit]);
const invalidateRecord = useCallback((key: string) => {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
commitRecords((prev) => {
if (!(key in prev)) return prev;
const { [key]: _removed, ...next } = prev;
return next;
}, true);
}, [commitRecords]);
const invalidateMatching = useCallback((matches: (key: string) => boolean) => {
for (const key of requestVersionRef.current.keys()) {
if (matches(key)) {
requestVersionRef.current.set(key, (requestVersionRef.current.get(key) ?? 0) + 1);
queuedForceRef.current.delete(key);
}
}
commitRecords((prev) => {
let changed = false;
const next: RecordMap<TValue> = {};
for (const [key, value] of Object.entries(prev) as Array<[string, AsyncRecordState<TValue>]>) {
if (matches(key)) {
changed = true;
continue;
}
next[key] = value;
}
return changed ? next : prev;
}, true);
}, [commitRecords]);
const refreshRecord = useCallback(
(item: TItem) => loadRecord(item, { force: true, urgent: true }),
[loadRecord],
);
return {
records,
loadRecord,
refreshRecord,
invalidateRecord,
invalidateMatching,
};
}
/** @deprecated Import from `@/application/state/systemManager/useAsyncRecordCache` instead. */
export {
useAsyncRecordCache,
type AsyncRecordState,
} from "../../../application/state/systemManager/useAsyncRecordCache";
@@ -1,326 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { I18nContextValue } from '../../../application/i18n/I18nProvider';
import { sessionCapabilitiesStore } from '../../../application/state/sessionCapabilitiesStore';
import type { SessionCapabilities } from '../../../domain/systemManager/types';
import type { useSystemManagerBackend } from '../../../application/state/useSystemManagerBackend';
import { nextPollData } from '../listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const lower = message.toLowerCase();
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
return t('systemManager.errors.sshChannelUnavailable');
}
return message;
}
/** Stable i18n ref so polling fetchers do not reset when locale re-renders. */
export function useStableTranslate(): I18nContextValue['t'] {
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
return useCallback(
(key, values) => tRef.current(key, values),
[],
);
}
export function useSessionCapabilities(
sessionId: string | null,
isConnected: boolean,
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
);
const [probing, setProbing] = useState(false);
useEffect(() => {
if (!sessionId) return undefined;
return sessionCapabilitiesStore.subscribe(sessionId, () => {
setCapabilities(sessionCapabilitiesStore.get(sessionId));
});
}, [sessionId]);
useEffect(() => {
if (!sessionId || isConnected) return undefined;
sessionCapabilitiesStore.delete(sessionId);
return undefined;
}, [sessionId, isConnected]);
const probe = useCallback(async (force = false) => {
if (!sessionId || !isConnected) return;
if (!force && sessionCapabilitiesStore.get(sessionId)) return;
setProbing(true);
try {
const result = await backend.probeSystemCapabilities(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
} finally {
setProbing(false);
}
}, [backend, isConnected, sessionId]);
useEffect(() => {
if (!sessionId || !isConnected || !enabled) return undefined;
void probe();
return undefined;
}, [enabled, sessionId, isConnected, probe]);
return { capabilities, probing, refreshCapabilities: () => probe(true) };
}
/** Prefetch capabilities only for the given session ids (e.g. when System panel opens). */
export function useSystemCapabilitiesWarmup(
sessionIds: string[],
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const backendRef = useRef(backend);
backendRef.current = backend;
const inflightRef = useRef(new Set<string>());
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
useEffect(() => {
if (!sessionKey) return undefined;
for (const sessionId of sessionKey.split(',')) {
if (!sessionId || sessionCapabilitiesStore.get(sessionId)) continue;
if (inflightRef.current.has(sessionId)) continue;
inflightRef.current.add(sessionId);
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
inflightRef.current.delete(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
});
}
return undefined;
}, [sessionKey]);
}
export function usePolling<T>(
fetcher: () => Promise<T | null>,
intervalMs: number,
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
options?: { poll?: boolean; resetKey?: string },
) {
const stableT = useStableTranslate();
const resetKey = options?.resetKey ?? '';
const [data, setData] = useState<T | null>(null);
const [dataKey, setDataKey] = useState(resetKey);
const [error, setError] = useState<string | null>(null);
const [errorKey, setErrorKey] = useState(resetKey);
const [loading, setLoading] = useState(false);
const [loadingKey, setLoadingKey] = useState(resetKey);
const failuresRef = useRef(0);
const hasDataRef = useRef(false);
const enabledRef = useRef(enabled);
const generationRef = useRef(0);
const runIdRef = useRef(0);
const loadingRunIdRef = useRef(0);
const inflightRef = useRef<{ generation: number; runId: number } | null>(null);
const queuedRunRef = useRef<{
options?: { withLoading?: boolean; minLoadingMs?: number };
resolve: () => void;
} | null>(null);
const fetcherRef = useRef(fetcher);
const mergeRef = useRef(merge);
const pollRef = useRef(options?.poll ?? true);
const resetKeyRef = useRef(resetKey);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
enabledRef.current = enabled;
fetcherRef.current = fetcher;
mergeRef.current = merge;
pollRef.current = options?.poll ?? true;
const clearPollTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const pollDelayMs = useCallback(() => {
if (failuresRef.current >= 3) return intervalMs * 4;
return intervalMs;
}, [intervalMs]);
const resolveQueuedRun = useCallback(() => {
queuedRunRef.current?.resolve();
queuedRunRef.current = null;
}, []);
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
const generation = generationRef.current;
const runResetKey = resetKeyRef.current;
if (!enabledRef.current) return;
if (inflightRef.current?.generation === generation) {
if (options?.withLoading) {
queuedRunRef.current?.resolve();
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(true);
return new Promise<void>((resolve) => {
queuedRunRef.current = { options, resolve };
});
}
return;
}
const runId = ++runIdRef.current;
inflightRef.current = { generation, runId };
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
const isCurrent = () => (
generationRef.current === generation
&& enabledRef.current
&& inflightRef.current?.runId === runId
&& resetKeyRef.current === runResetKey
);
if (showLoading) {
loadingRunIdRef.current = runId;
setLoadingKey(runResetKey);
setLoading(true);
}
try {
const result = await fetcherRef.current();
if (!isCurrent()) return;
if (result !== null) {
setDataKey(runResetKey);
setData((prev) => {
const mergeFn = mergeRef.current;
const next = mergeFn ? mergeFn(prev, result) : nextPollData(prev, result);
if (next !== prev) hasDataRef.current = true;
return next;
});
setErrorKey(runResetKey);
setError(null);
failuresRef.current = 0;
}
} catch (err) {
if (!isCurrent()) return;
failuresRef.current += 1;
setDataKey(runResetKey);
setData(null);
hasDataRef.current = false;
setErrorKey(runResetKey);
setError(normalizePollingErrorMessage(err, stableT));
} finally {
if (inflightRef.current?.runId === runId) {
inflightRef.current = null;
}
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
if (
generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
&& loadingRunIdRef.current === runId
) {
loadingRunIdRef.current = 0;
setLoadingKey(runResetKey);
setLoading(false);
}
}
const queued = queuedRunRef.current;
if (
queued
&& generationRef.current === generation
&& enabledRef.current
&& resetKeyRef.current === runResetKey
) {
queuedRunRef.current = null;
await run(queued.options);
queued.resolve();
}
}
}, [stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
if (!enabledRef.current || !pollRef.current) return;
const generation = generationRef.current;
timerRef.current = setTimeout(() => {
void run({ withLoading: false }).finally(() => {
if (generationRef.current === generation) {
scheduleNextPoll();
}
});
}, pollDelayMs());
}, [clearPollTimer, pollDelayMs, run]);
useEffect(() => {
const resetChanged = resetKeyRef.current !== resetKey;
resetKeyRef.current = resetKey;
generationRef.current += 1;
inflightRef.current = null;
clearPollTimer();
if (!enabled) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
return undefined;
}
if (resetChanged) {
resolveQueuedRun();
loadingRunIdRef.current = 0;
setLoading(false);
setLoadingKey(resetKey);
setDataKey(resetKey);
setData(null);
setErrorKey(resetKey);
setError(null);
failuresRef.current = 0;
hasDataRef.current = false;
}
const generation = generationRef.current;
void run({ withLoading: true }).finally(() => {
if (generationRef.current === generation && pollRef.current) scheduleNextPoll();
});
return () => {
generationRef.current += 1;
resolveQueuedRun();
loadingRunIdRef.current = 0;
inflightRef.current = null;
clearPollTimer();
};
}, [clearPollTimer, enabled, intervalMs, options?.poll, resetKey, resolveQueuedRun, run, scheduleNextPoll]);
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return {
data: dataKey === resetKey ? data : null,
error: errorKey === resetKey ? error : null,
loading: loadingKey === resetKey ? loading : enabled,
refresh,
};
}
/** @deprecated Import from `@/application/state/useSystemManager` instead. */
export {
useStableTranslate,
useSessionCapabilities,
useSystemCapabilitiesWarmup,
usePolling,
} from "../../../application/state/useSystemManager";
+1 -1
View File
@@ -6,7 +6,7 @@ import { cn } from '../../lib/utils';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from './hooks/useServerStats';
import { useServerStats } from '../../application/state/useServerStats';
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
interface TerminalServerStatsProps {
+8 -415
View File
@@ -1,415 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
kernelRelease?: string; // Remote kernel release
uptimeSeconds?: number | null;// Remote uptime in seconds
loadAverage?: number[]; // 1/5/15 minute load average
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
interface ServerStatsState {
stats: ServerStats;
isLoading: boolean;
error: string | null;
}
interface ServerStatsClient {
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
type ServerStatsListener = (state: ServerStatsState) => void;
interface SharedServerStatsSession {
sessionId: string;
state: ServerStatsState;
listeners: Set<ServerStatsListener>;
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
consecutiveFailures: number;
givenUp: boolean;
pollIntervalMs: number | null;
pollingActive: boolean;
inflight: Promise<void> | null;
inflightGeneration: number | null;
}
const CONSECUTIVE_FAILURE_LIMIT = 3;
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
let nextClientId = 1;
function createEmptyServerStats(): ServerStats {
return {
hostname: undefined,
osName: undefined,
kernelRelease: undefined,
uptimeSeconds: null,
loadAverage: [],
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
};
}
function createInitialState(): ServerStatsState {
return {
stats: createEmptyServerStats(),
isLoading: false,
error: null,
};
}
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
const existing = serverStatsSessions.get(sessionId);
if (existing) return existing;
const session: SharedServerStatsSession = {
sessionId,
state: createInitialState(),
listeners: new Set(),
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
consecutiveFailures: 0,
givenUp: false,
pollIntervalMs: null,
pollingActive: false,
inflight: null,
inflightGeneration: null,
};
serverStatsSessions.set(sessionId, session);
return session;
}
function emitServerStatsState(session: SharedServerStatsSession): void {
for (const listener of session.listeners) {
listener(session.state);
}
}
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
session.state = { ...session.state, ...patch };
emitServerStatsState(session);
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
}
if (session.intervalRef) {
clearInterval(session.intervalRef);
session.intervalRef = null;
}
session.pollingActive = false;
session.pollIntervalMs = null;
}
function resetServerStatsSession(session: SharedServerStatsSession): void {
clearServerStatsTimers(session);
session.connectedAt = 0;
session.hasFetched = false;
session.fetchGeneration += 1;
session.consecutiveFailures = 0;
session.givenUp = false;
updateServerStatsState(session, createInitialState());
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
));
}
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
return {
hostname: stats.hostname,
osName: stats.osName,
kernelRelease: stats.kernelRelease,
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
memBuffers: stats.memBuffers ?? null,
memCached: stats.memCached ?? null,
swapTotal: stats.swapTotal ?? null,
swapUsed: stats.swapUsed ?? null,
topProcesses: stats.topProcesses || [],
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
netInterfaces: stats.netInterfaces || [],
lastUpdated: Date.now(),
};
}
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
session.consecutiveFailures += 1;
updateServerStatsState(session, { error: message });
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. This covers network devices that advertise
// OpenSSH but do not support the POSIX stats shell command.
session.givenUp = true;
clearServerStatsTimers(session);
}
}
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
if (!session.sessionId) return;
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
await session.inflight;
return;
}
if (session.givenUp && !force) return;
if (getActiveServerStatsClients(session).length === 0) return;
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return;
if (force) {
session.givenUp = false;
session.consecutiveFailures = 0;
}
const generation = session.fetchGeneration;
session.inflightGeneration = generation;
updateServerStatsState(session, { isLoading: true, error: null });
const request = (async () => {
try {
const result = await bridge.getServerStats(session.sessionId);
// Discard stale responses from before a polling restart or reconnect.
if (generation !== session.fetchGeneration) return;
if (result.pending) {
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
// has not finished. Do not count this toward the hard-failure cutoff.
return;
}
if (result.success && result.stats) {
session.hasFetched = true;
session.consecutiveFailures = 0;
updateServerStatsState(session, {
stats: normalizeServerStats(result.stats),
error: null,
});
} else if (result.error) {
markServerStatsFailure(session, result.error);
} else {
markServerStatsFailure(session, 'No stats returned');
}
} catch (err) {
if (generation === session.fetchGeneration) {
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (generation === session.fetchGeneration) {
updateServerStatsState(session, { isLoading: false });
}
}
})();
session.inflight = request;
try {
await request;
} finally {
if (session.inflight === request) {
session.inflight = null;
session.inflightGeneration = null;
}
}
}
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
const activeClients = getActiveServerStatsClients(session);
if (activeClients.length === 0) {
resetServerStatsSession(session);
return;
}
if (session.connectedAt === 0) {
session.connectedAt = Date.now();
}
const intervalMs = Math.max(5, Math.min(...activeClients.map((client) => client.refreshInterval))) * 1000;
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
if (!shouldRestartPolling) return;
if (session.givenUp) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
session.pollingActive = true;
session.pollIntervalMs = intervalMs;
const connectionAge = Date.now() - session.connectedAt;
const needsWarmup = !session.hasFetched && connectionAge < 2000;
session.initialTimerRef = setTimeout(() => {
session.initialTimerRef = null;
void fetchSharedServerStats(session);
}, needsWarmup ? 2000 : 0);
session.intervalRef = setInterval(() => {
void fetchSharedServerStats(session);
}, intervalMs);
}
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
if (session.clients.size > 0 || session.listeners.size > 0) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
serverStatsSessions.delete(session.sessionId);
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const clientIdRef = useRef<number | null>(null);
if (clientIdRef.current === null) {
clientIdRef.current = nextClientId;
nextClientId += 1;
}
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
const listener: ServerStatsListener = (nextState) => {
setState(nextState);
};
session.listeners.add(listener);
setState(session.state);
return () => {
session.listeners.delete(listener);
session.clients.delete(clientId);
reconcileSharedServerStatsSession(session);
maybeDisposeSharedServerStatsSession(session);
};
}, [sessionId]);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
session.clients.set(clientId, {
enabled,
refreshInterval,
isSupportedOs,
isConnected,
});
setState(session.state);
reconcileSharedServerStatsSession(session);
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected]);
const refresh = useCallback(() => {
const session = getSharedServerStatsSession(sessionId);
void fetchSharedServerStats(session, true).finally(() => {
reconcileSharedServerStatsSession(session);
});
}, [sessionId]);
return {
stats: state.stats,
isLoading: state.isLoading,
error: state.error,
refresh,
};
}
/** @deprecated Import from `@/application/state/useServerStats` instead. */
export {
useServerStats,
type DiskInfo,
type NetInterfaceInfo,
type ProcessInfo,
type ServerStats,
} from "../../../application/state/useServerStats";
+3 -3
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import test from "node:test";
test("server stats retry can restart polling after give-up", () => {
const source = readFileSync(new URL("./hooks/useServerStats.ts", import.meta.url), "utf8");
const source = readFileSync(new URL("../../application/state/useServerStats.ts", import.meta.url), "utf8");
const reconcileStart = source.indexOf("function reconcileSharedServerStatsSession");
const shouldRestartCheck = source.indexOf("const shouldRestartPolling", reconcileStart);
const givenUpCheck = source.indexOf("if (session.givenUp) return;", shouldRestartCheck);
@@ -17,7 +17,7 @@ test("server stats retry can restart polling after give-up", () => {
});
test("server stats stale requests do not block a fresh visible fetch", () => {
const source = readFileSync(new URL("./hooks/useServerStats.ts", import.meta.url), "utf8");
const source = readFileSync(new URL("../../application/state/useServerStats.ts", import.meta.url), "utf8");
assert.match(source, /inflightGeneration: number \| null/);
assert.match(source, /session\.inflight && session\.inflightGeneration === session\.fetchGeneration/);
@@ -25,7 +25,7 @@ test("server stats stale requests do not block a fresh visible fetch", () => {
});
test("server stats polling does not stop when its terminal is in the background", () => {
const source = readFileSync(new URL("./hooks/useServerStats.ts", import.meta.url), "utf8");
const source = readFileSync(new URL("../../application/state/useServerStats.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /isVisible/);
assert.doesNotMatch(source, /getVisibleServerStatsClients/);
@@ -7,13 +7,13 @@ import { useSystemManagerBackend } from '../../application/state/useSystemManage
import { canReuseTerminalConnection } from '../../application/state/terminalConnectionReuse';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import { useSystemCapabilitiesWarmup } from '../systemManager/hooks/useSystemManager';
import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemManager';
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, Workspace } from '../../types';
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
import { TerminalLayerView } from './TerminalLayerView';
import { useTerminalAiContexts } from './useTerminalAiContexts';
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
import { useTerminalLayerEffects } from './useTerminalLayerEffects';
import { useTerminalThemePanelState } from './useTerminalThemePanelState';
import { useManualTerminalChromeSurfaceInjection } from '../../application/state/useManualTerminalChromeSurfaceInjection';
@@ -1,143 +1,2 @@
import type React from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { retainStableAiPanelContexts } from '../../domain/aiPanelContextsEqual';
import { collectSessionIds } from '../../domain/workspace';
import type { TerminalContextReader } from '../../domain/terminalContextRead';
import { detectLocalOs } from '../../lib/localShell';
import type { Host, PortForwardingRule, TerminalSession, Workspace } from '../../types';
import { buildAITerminalSessionInfo, type AIPanelContext } from './TerminalLayerSupport';
interface UseTerminalAiContextsOptions {
hosts: Host[];
hostsRef: React.MutableRefObject<Host[]>;
portForwardingRules: PortForwardingRule[];
portForwardingRulesRef: React.MutableRefObject<PortForwardingRule[]>;
mountedAiTabIds: string[];
sessionHostsMap: Map<string, Host>;
sessions: TerminalSession[];
sessionsRef: React.MutableRefObject<TerminalSession[]>;
terminalContextReadersRef: React.MutableRefObject<Map<string, TerminalContextReader>>;
workspaces: Workspace[];
workspacesRef: React.MutableRefObject<Workspace[]>;
}
export function useTerminalAiContexts({
hosts,
hostsRef,
portForwardingRules,
portForwardingRulesRef,
mountedAiTabIds,
sessionHostsMap,
sessions,
sessionsRef,
terminalContextReadersRef,
workspaces,
workspacesRef,
}: UseTerminalAiContextsOptions) {
const previousAiContextsRef = useRef<Map<string, AIPanelContext> | null>(null);
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const allHosts = hosts;
const tabIds = new Set<string>(mountedAiTabIds);
const contexts = new Map<string, AIPanelContext>();
for (const tabId of tabIds) {
const workspace = workspaceById.get(tabId);
if (workspace) {
const sessionIds = collectSessionIds(workspace.root);
contexts.set(tabId, {
scopeType: 'workspace',
scopeTargetId: workspace.id,
scopeHostIds: sessionIds
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
sessionHostsMap.get(sessionId),
localOs,
{ allHosts, portForwardingRules },
),
),
});
continue;
}
const session = sessionById.get(tabId);
if (!session) continue;
contexts.set(tabId, {
scopeType: 'terminal',
scopeTargetId: session.id,
scopeHostIds: session.hostId ? [session.hostId] : [],
scopeLabel: session.hostLabel ?? '',
terminalSessions: [
buildAITerminalSessionInfo(
session,
sessionHostsMap.get(session.id),
localOs,
{ allHosts, portForwardingRules },
),
],
});
}
const retained = retainStableAiPanelContexts(previousAiContextsRef.current, contexts);
previousAiContextsRef.current = retained;
return retained;
}, [sessions, workspaces, mountedAiTabIds, sessionHostsMap, hosts, portForwardingRules]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => {
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session ? sessionHostsMap.get(session.id) : undefined;
return buildAITerminalSessionInfo(session, host, localOs, {
allHosts: latestHosts,
portForwardingRules: portForwardingRulesRef.current,
});
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
readTerminalContext: (request: Parameters<TerminalContextReader>[0]) => {
const reader = terminalContextReadersRef.current.get(request.sessionId);
if (!reader) {
return Promise.resolve({
ok: false as const,
error: `Terminal session "${request.sessionId}" has no readable terminal buffer.`,
});
}
return reader(request);
},
};
}, [hostsRef, portForwardingRulesRef, sessionHostsMap, sessionsRef, terminalContextReadersRef, workspacesRef]);
return {
aiContextsByTabId,
resolveAIExecutorContext,
};
}
/** @deprecated Import from `@/application/state/useTerminalAiContexts` instead. */
export { useTerminalAiContexts } from "../../application/state/useTerminalAiContexts";
+2 -713
View File
@@ -1,713 +1,2 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import {
readRememberedKeyPassphrases,
rememberImportedKeyPassphrase,
resolveDefaultKeyPassphraseAliases,
} from "../../application/defaultKeyPassphrases";
import {
countVaultImportDuplicates,
ensureVaultImportPersisted,
mergeVaultImportedGroups,
rebaseVaultImportedHosts,
resolveUniqueManagedImportGroupName,
rollbackVaultImportedHosts,
waitForVaultImportProgressPaint,
type VaultHostPersistenceResult,
type VaultImportProgress,
} from "../../application/state/vaultImportProgress";
import { importVaultHostsInWorker } from "../../application/state/vaultImportWorker";
import {
type VaultLockHandle,
withVaultImportLock,
} from "../../application/state/vaultManagedImportLock";
/** Exit the import lock so a concurrent host save can finish, then retry. */
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
import { sanitizeHost } from "../../domain/host";
import {
applyVaultImportDestination,
applyVaultHostImport,
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
type VaultImportFormat,
} from "../../domain/vaultImport";
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
import type { ImportOptions } from "./ImportVaultDialog";
import { toast } from "../ui/toast";
interface UseVaultImportHandlersOptions {
customGroups: string[];
hosts: Host[];
keys: SSHKey[];
managedSources: ManagedSource[];
onReadPersistedHosts: () => Promise<Host[]>;
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
onUpdateKeys: (keys: SSHKey[]) => void;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
setIsImportOpen: (open: boolean) => void;
t: (key: string, values?: Record<string, unknown>) => string;
}
export function useVaultImportHandlers({
customGroups,
hosts,
keys,
managedSources,
onReadPersistedHosts,
onUpdateHosts,
onUpdateKeys,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
setIsImportOpen,
t,
}: UseVaultImportHandlersOptions) {
const [importProgress, setImportProgress] = useState<VaultImportProgress | null>(null);
const customGroupsRef = useRef(customGroups);
const hostsRef = useRef(hosts);
const keysRef = useRef(keys);
const managedSourcesRef = useRef(managedSources);
const activeImportAbortRef = useRef<AbortController | null>(null);
const importCommitStartedRef = useRef(false);
const importInFlightRef = useRef(false);
customGroupsRef.current = customGroups;
hostsRef.current = hosts;
keysRef.current = keys;
managedSourcesRef.current = managedSources;
const resetImportProgress = useCallback(() => setImportProgress(null), []);
useEffect(() => () => {
if (!importCommitStartedRef.current) activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
}, []);
const cancelImport = useCallback(() => {
if (importCommitStartedRef.current) return;
activeImportAbortRef.current?.abort();
activeImportAbortRef.current = null;
setImportProgress(null);
setIsImportOpen(false);
}, [setIsImportOpen]);
const handleImportFileSelected = useCallback(
async (format: VaultImportFormat, files: File[], options?: ImportOptions) => {
const file = files[0];
if (!file) return;
if (importInFlightRef.current) return;
importInFlightRef.current = true;
activeImportAbortRef.current?.abort();
const abortController = new AbortController();
activeImportAbortRef.current = abortController;
importCommitStartedRef.current = false;
const { signal } = abortController;
const throwIfCancelled = () => {
if (signal.aborted) {
throw new DOMException("Vault import cancelled.", "AbortError");
}
};
let rollbackSnapshot: {
baselineHosts: Host[];
appliedHosts: Host[];
} | null = null;
let rollbackPendingImport: () => Promise<void> = async () => undefined;
const relativeRoot = file.webkitRelativePath?.split(/[\\/]+/).filter(Boolean)[0];
const selectionName = files.length > 1 ? (relativeRoot || file.name) : file.name;
const formatLabel =
format === "putty"
? "PuTTY"
: format === "mobaxterm"
? "MobaXterm"
: format === "csv"
? "CSV"
: format === "securecrt"
? "SecureCRT"
: "ssh_config";
const updateProgress = (next: Partial<VaultImportProgress>) => {
setImportProgress((current) => ({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
totalFiles: files.length,
...current,
...next,
}));
};
setIsImportOpen(false);
setImportProgress({
status: "running",
stage: "reading",
percent: 5,
formatLabel,
fileName: selectionName,
completedFiles: 0,
totalFiles: files.length,
});
try {
let result = await importVaultHostsInWorker({
format,
files,
encoding: options?.encoding,
signal,
onProgress: (progress) => {
if (!signal.aborted) updateProgress(progress);
},
});
throwIfCancelled();
const isManaged = format === "ssh_config" && options?.managed === true;
if (!isManaged) {
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
);
}
updateProgress({ stage: "preparing", percent: 70 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
updateProgress({ stage: "saving", percent: 85 });
await waitForVaultImportProgressPaint();
throwIfCancelled();
const currentCustomGroups = customGroupsRef.current;
const currentHosts = hostsRef.current;
const currentManagedSources = managedSourcesRef.current;
const persistHosts = async (
nextHosts: Host[],
options?: {
baselineHosts?: Host[];
persistAttempt?: (
hosts: Host[],
baselineHosts: Host[],
) => Promise<VaultHostPersistenceResult>;
prepareAttempt?: (attempt: { baselineHosts: Host[]; appliedHosts: Host[] }) => Host[];
},
) => {
throwIfCancelled();
importCommitStartedRef.current = true;
let baselineHosts = options?.baselineHosts ?? currentHosts;
let appliedHosts = nextHosts;
while (true) {
appliedHosts = options?.prepareAttempt?.({ baselineHosts, appliedHosts }) ?? appliedHosts;
rollbackSnapshot = { baselineHosts, appliedHosts };
let persisted: VaultHostPersistenceResult;
if (options?.persistAttempt) {
persisted = await options.persistAttempt(appliedHosts, baselineHosts);
} else {
let hostUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
hostUpdate = onUpdateHosts(appliedHosts);
});
persisted = await hostUpdate!;
}
if (persisted !== "superseded") {
if (persisted !== false) rollbackSnapshot = null;
return persisted;
}
// Locked import commits must release the shared lock before retrying
// so a concurrent host save queued behind that lock can finish first.
if (options?.persistAttempt) {
throw RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT;
}
const latestHosts = hostsRef.current;
hostsRef.current = latestHosts;
appliedHosts = rebaseVaultImportedHosts({
currentHosts: latestHosts,
baselineHosts,
appliedHosts,
});
baselineHosts = latestHosts;
}
};
rollbackPendingImport = async () => {
const snapshot = rollbackSnapshot;
if (!snapshot) return;
while (true) {
const rollbackHosts = rollbackVaultImportedHosts({
currentHosts: hostsRef.current,
...snapshot,
});
let rollbackUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
startTransition(() => {
rollbackUpdate = onUpdateHosts(rollbackHosts);
});
const persisted = await rollbackUpdate!;
if (persisted === "superseded") continue;
if (persisted === false) {
throw new Error(t("vault.import.progress.persistFailed"));
}
rollbackSnapshot = null;
return;
}
};
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
const requestedManagedGroup = options?.destination?.mode === "preserve"
? null
: options?.destination?.group ?? null;
let managedGroupName = requestedManagedGroup ?? `${fileBaseName} - Managed`;
// Check if this file is already managed
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
if (isManaged && !filePath) {
// Cannot proceed with managed import without a valid file path
const message = t("vault.import.sshConfig.noFilePathDesc");
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.sshConfig.noFilePath"),
);
return;
}
if (isManaged) {
const existingSource = currentManagedSources.find(s => s.filePath === filePath);
if (existingSource) {
const message = t("vault.import.sshConfig.alreadyManagedDesc", {
group: existingSource.groupName,
});
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.sshConfig.alreadyManaged"),
);
return;
}
}
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
// group as part of session identity so direct vs proxy copies can coexist.
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
// For managed imports, also update existing hosts to be managed
let updatedExistingHosts: Host[] = [];
if (isManaged) {
const importedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = currentHosts.filter((h) => importedKeys.has(makeKey(h)));
}
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const latestPersistedSources = onReadPersistedManagedSources();
managedSourcesRef.current = latestPersistedSources;
const sourceClaim = latestPersistedSources.find((source) => source.filePath === filePath);
if (sourceClaim) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: sourceClaim.groupName,
}));
}
const managedBaselineHosts = await onReadPersistedHosts();
hostsRef.current = managedBaselineHosts;
const managedExistingKeys = new Set(managedBaselineHosts.map(makeKey));
newHosts = result.hosts.filter((host) => !managedExistingKeys.has(makeKey(host)));
const managedImportedKeys = new Set(result.hosts.map(makeKey));
updatedExistingHosts = managedBaselineHosts.filter((host) => (
!host.managedSourceId && managedImportedKeys.has(makeKey(host))
));
if (newHosts.length === 0 && updatedExistingHosts.length === 0) return;
const sourceId = crypto.randomUUID();
let newSource: ManagedSource = {
id: sourceId,
type: "ssh_config",
filePath: filePath,
groupName: managedGroupName,
lastSyncedAt: Date.now(),
};
newHosts = newHosts.map((h) => ({
...h,
group: managedGroupName,
// Only SSH hosts can be managed (SSH config only supports SSH)
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
}));
// Update existing hosts to be managed (move to managed group)
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
const updatedHosts = managedBaselineHosts.map((h) => {
if (!existingHostIds.has(h.id)) return h;
const canBeManaged = !h.protocol || h.protocol === "ssh";
return {
...h,
group: managedGroupName,
managedSourceId: canBeManaged ? sourceId : undefined,
// Sanitize label for managed hosts
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
};
});
let nextGroups: string[] = [];
const ensureManagedSourceStillAvailable = () => {
const conflictingSource = managedSourcesRef.current.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
};
const prepareManagedAttempt = ({
baselineHosts,
appliedHosts,
}: {
baselineHosts: Host[];
appliedHosts: Host[];
}) => {
ensureManagedSourceStillAvailable();
managedGroupName = requestedManagedGroup ?? resolveUniqueManagedImportGroupName({
baseName: fileBaseName,
customGroups: customGroupsRef.current,
hosts: baselineHosts,
managedSources: managedSourcesRef.current,
ownerSourceId: sourceId,
});
newSource = { ...newSource, groupName: managedGroupName };
nextGroups = Array.from(new Set([
...currentCustomGroups,
...(requestedManagedGroup ? [] : result.groups),
managedGroupName,
]));
return appliedHosts.map((host) => (
host.managedSourceId === sourceId
? { ...host, group: managedGroupName }
: host
));
};
const hostPersisted = await persistHosts(
[...updatedHosts, ...newHosts].map((host: Host) => sanitizeHost(host)),
{
baselineHosts: managedBaselineHosts,
prepareAttempt: prepareManagedAttempt,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: currentCustomGroups,
appliedGroups: nextGroups,
}),
(latestPersistedSources) => {
const conflictingSource = latestPersistedSources.find((source) => (
source.id !== sourceId && source.filePath === filePath
));
if (conflictingSource) {
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
group: conflictingSource.groupName,
}));
}
return [
...latestPersistedSources.filter((source) => source.id !== newSource.id),
newSource,
];
},
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
},
);
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
// Drain concurrent locked writes outside the critical section.
hostsRef.current = await onReadPersistedHosts();
}
}
} else if (newHosts.length > 0) {
let addedHostIds = new Set<string>();
let addedHostKeyPaths = new Map<string, string>();
while (true) {
try {
await withVaultImportLock("vault", async (lock) => {
const importBaselineHosts = await onReadPersistedHosts();
hostsRef.current = importBaselineHosts;
const importBaselineGroups = customGroupsRef.current;
const merged = applyVaultHostImport(
importBaselineHosts,
importBaselineGroups,
result,
{ skipDuplicates: format !== "securecrt" },
);
newHosts = merged.addedHosts;
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
return keyPath ? [[host.id, keyPath] as const] : [];
}));
if (newHosts.length === 0) return;
const hostPersisted = await persistHosts(merged.hosts, {
baselineHosts: importBaselineHosts,
persistAttempt: async (hostsToCommit, baselineHosts) => {
const transaction = await onCommitVaultImportTransaction(
hostsToCommit,
(latestPersistedGroups) => mergeVaultImportedGroups({
currentGroups: latestPersistedGroups,
baselineGroups: importBaselineGroups,
appliedGroups: merged.customGroups,
}),
(latestPersistedSources) => latestPersistedSources,
undefined,
baselineHosts,
lock,
);
if (transaction.status === "superseded") return "superseded";
customGroupsRef.current = transaction.groups;
managedSourcesRef.current = transaction.sources;
return true;
},
});
await ensureVaultImportPersisted(
hostPersisted,
t("vault.import.progress.persistFailed"),
undefined,
rollbackPendingImport,
);
});
break;
} catch (error) {
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
hostsRef.current = await onReadPersistedHosts();
}
}
throwIfCancelled();
const resolved = await resolveVaultImportKeyPassphraseConflicts(
result.keyPassphraseCandidates ?? result.keyPassphrases ?? [],
resolveDefaultKeyPassphraseAliases,
addedHostIds,
addedHostKeyPaths,
);
throwIfCancelled();
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
resolved.keyPassphrases,
(keyPath) => readRememberedKeyPassphrases(keyPath, keysRef.current),
);
throwIfCancelled();
result.issues = mergeVaultImportIssues(
result.issues,
resolved.issues,
checked.issues,
);
for (const entry of checked.keyPassphrases) {
try {
const saved = await rememberImportedKeyPassphrase({
keyPath: entry.keyPath,
passphrase: entry.passphrase,
keys: keysRef.current,
getKeys: () => keysRef.current,
updateKeys: onUpdateKeys,
setCurrentKeys: (updatedKeys) => {
keysRef.current = updatedKeys;
},
});
throwIfCancelled();
if (saved === "conflict") {
result.issues.push({
level: "warning",
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
});
} else if (saved === "unreadable") {
result.issues.push({
level: "warning",
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
});
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
result.issues.push({
level: "warning",
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
});
}
}
result.issues = mergeVaultImportIssues(result.issues);
}
// Count total hosts affected (new + converted to managed)
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
const skipped = result.stats.skipped;
const duplicates = countVaultImportDuplicates({
importedHostCount: result.hosts.length,
newHostCount: newHosts.length,
fileDuplicateCount: result.stats.duplicates,
managed: isManaged,
});
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
if (result.stats.parsed === 0 && totalAffected === 0) {
const message = t("vault.import.toast.noEntries", { format: formatLabel });
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(
message,
t("vault.import.toast.failedTitle"),
);
return;
}
if (totalAffected === 0) {
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: 0,
skipped,
duplicates,
});
toast.warning(
t("vault.import.toast.noNewHosts", { format: formatLabel }),
t("vault.import.toast.completedTitle"),
);
return;
}
throwIfCancelled();
rollbackSnapshot = null;
if (isManaged) {
toast.success(
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
t("vault.import.toast.completedTitle"),
);
} else {
const details = t("vault.import.toast.summary", {
count: totalAffected,
skipped,
duplicates,
});
if (hasWarnings) {
const firstIssue = result.issues[0]?.message;
toast.warning(
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
t("vault.import.toast.completedTitle"),
);
} else {
toast.success(details, t("vault.import.toast.completedTitle"));
}
}
updateProgress({
status: "complete",
stage: "complete",
percent: 100,
imported: totalAffected,
skipped,
duplicates,
});
} catch (err) {
let rollbackFailure: unknown;
if (rollbackSnapshot) {
try {
await rollbackPendingImport();
} catch (rollbackError) {
rollbackFailure = rollbackError;
console.error("[vault import] Failed to rollback imported hosts.", rollbackError);
}
}
if (
!rollbackFailure
&& (
signal.aborted
|| (err instanceof DOMException && err.name === "AbortError")
)
) return;
const originalMessage =
err instanceof Error ? err.message : t("common.unknownError");
const message = rollbackFailure
? `${originalMessage} ${t("vault.import.progress.rollbackFailed")}`
: originalMessage;
updateProgress({
status: "error",
stage: "failed",
error: message,
});
toast.error(message, t("vault.import.toast.failedTitle"));
} finally {
if (activeImportAbortRef.current === abortController) {
activeImportAbortRef.current = null;
}
importCommitStartedRef.current = false;
importInFlightRef.current = false;
}
},
[
onReadPersistedHosts,
onCommitVaultImportTransaction,
onReadPersistedManagedSources,
onUpdateHosts,
onUpdateKeys,
setIsImportOpen,
t,
],
);
return {
cancelImport,
handleImportFileSelected,
importProgress,
resetImportProgress,
};
}
/** @deprecated Import from `@/application/state/useVaultImportHandlers` instead. */
export { useVaultImportHandlers } from "../../application/state/useVaultImportHandlers";