mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c98e688ad6 | ||
|
|
5c2d60f694 |
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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
compactionStatusText,
|
||||
resolveCompactionStatusText,
|
||||
type ActiveCompactionUi,
|
||||
} from './hooks/useAgentCompactionUi';
|
||||
} from '../../application/state/useAgentCompactionUi';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user