Files
Netcatty/application/state/useRemoteHistoryState.ts
陈大猫 ae209d37c1 feat(terminal): remote command history side panel (#1385)
* feat(terminal): add remote command history side panel

Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes #1381.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): expand command detail inline below selected row

Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): filter Netcatty AI PTY commands from remote history

Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): tighten detail strip and add run action

Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(history): address review findings before merge

Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:19:31 +08:00

175 lines
5.2 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from '../../domain/remoteHistory';
import type { RemoteHistoryEntry } from '../../domain/models';
export interface RemoteHistoryHostState {
entries: RemoteHistoryEntry[];
loading: boolean;
error: string | null;
fetchedAt: number | null;
}
const EMPTY_STATE: RemoteHistoryHostState = {
entries: [],
loading: false,
error: null,
fetchedAt: null,
};
const PENDING_RETRY_MS = 1500;
const PENDING_MAX_RETRIES = 12;
export interface UseRemoteHistoryState {
getState: (
hostId: string | null | undefined,
sessionId?: string | null,
) => RemoteHistoryHostState;
fetch: (sessionId: string, hostId: string) => Promise<void>;
clear: (hostId: string, sessionId?: string | null) => void;
}
function cacheKey(hostId: string, sessionId: string): string {
return `${hostId}\0${sessionId}`;
}
/**
* Owns per-session remote shell history state. Fetches the remote host's shell
* history via the SSH bridge — which detects the login shell and returns only
* the matching file(s) — parses and de-dupes them, and keeps an in-memory
* cache keyed by (hostId, sessionId). The cache is intentionally not persisted
* — history files can contain sensitive content.
*/
export function useRemoteHistoryState(): UseRemoteHistoryState {
const [byKey, setByKey] = useState<Record<string, RemoteHistoryHostState>>({});
const requestIdByKey = useRef<Record<string, number>>({});
const getState = useCallback(
(
hostId: string | null | undefined,
sessionId?: string | null,
): RemoteHistoryHostState => {
if (!hostId || !sessionId) return EMPTY_STATE;
return byKey[cacheKey(hostId, sessionId)] ?? EMPTY_STATE;
},
[byKey],
);
const fetch = useCallback(async (sessionId: string, hostId: string) => {
if (!sessionId || !hostId) return;
const key = cacheKey(hostId, sessionId);
const bridge = netcattyBridge.get();
if (!bridge?.readRemoteHistory) {
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: 'Remote history is not available in this build.',
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const reqId = (requestIdByKey.current[key] ?? 0) + 1;
requestIdByKey.current[key] = reqId;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: true,
error: null,
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
const isStale = () => requestIdByKey.current[key] !== reqId;
try {
for (let attempt = 0; attempt <= PENDING_MAX_RETRIES; attempt += 1) {
const result = await bridge.readRemoteHistory(sessionId, 1000);
if (isStale()) return;
if (!result?.success) {
if (result?.pending && attempt < PENDING_MAX_RETRIES) {
await new Promise((resolve) => {
window.setTimeout(resolve, PENDING_RETRY_MS);
});
if (isStale()) return;
continue;
}
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: result?.pending
? 'Remote history is not ready yet. Try again shortly.'
: (result?.error || 'Failed to read remote history'),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const lists: RemoteHistoryEntry[][] = [];
if (result.shell === 'bash') {
lists.push(parseBashHistory(result.bash ?? ''));
} else if (result.shell === 'zsh') {
lists.push(parseZshHistory(result.zsh ?? ''));
} else if (result.shell === 'fish') {
lists.push(parseFishHistory(result.fish ?? ''));
} else {
lists.push(parseBashHistory(result.bash ?? ''));
lists.push(parseZshHistory(result.zsh ?? ''));
lists.push(parseFishHistory(result.fish ?? ''));
}
const merged = mergeRemoteHistory(lists);
setByKey((prev) => ({
...prev,
[key]: {
entries: merged,
loading: false,
error: null,
fetchedAt: Date.now(),
},
}));
return;
}
} catch (err) {
if (isStale()) return;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: err instanceof Error ? err.message : String(err),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
}
}, []);
const clear = useCallback((hostId: string, sessionId?: string | null) => {
const key = sessionId ? cacheKey(hostId, sessionId) : hostId;
requestIdByKey.current[key] = (requestIdByKey.current[key] ?? 0) + 1;
setByKey((prev) => {
if (!(key in prev)) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}, []);
return { getState, fetch, clear };
}