Files
nyaterm/src/hooks/useCommandHistory.ts
T

190 lines
5.3 KiB
TypeScript

import type { Terminal } from "@xterm/xterm";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@/lib/invoke";
import {
canSuggestFromTracker,
getTrackedCommand,
type TerminalInputState,
} from "@/lib/terminalInputTracker";
import type { FuzzyResult } from "@/types/global";
interface XTermCoreWithRenderDimensions {
_core?: {
_renderService?: {
dimensions?: {
css: {
cell: {
height: number;
width: number;
};
};
};
};
};
}
export function useCommandHistory(
terminalRef: React.RefObject<Terminal | null>,
inputStateRef: React.RefObject<TerminalInputState>,
applySuggestion: (command: string, execute: boolean) => void,
enabled: boolean,
) {
const [suggestions, setSuggestions] = useState<FuzzyResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ top: 0, left: 0 });
const suggestionsRef = useRef<FuzzyResult[]>([]);
const selectedIndexRef = useRef(-1);
const showSuggestionsRef = useRef(false);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const enabledRef = useRef(enabled);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
const getCursorViewportPosition = useCallback((): { top: number; left: number } => {
try {
const terminal = terminalRef.current;
if (!terminal) return { top: 0, left: 0 };
const core = (terminal as Terminal & XTermCoreWithRenderDimensions)._core;
const dims = core?._renderService?.dimensions;
if (!dims) return { top: 0, left: 0 };
const cellHeight: number = dims.css.cell.height;
const cellWidth: number = dims.css.cell.width;
const cursorY = terminal.buffer.active.cursorY;
const cursorX = terminal.buffer.active.cursorX;
const screenEl = terminal.element?.querySelector(".xterm-screen");
if (!screenEl) return { top: 0, left: 0 };
const screenRect = screenEl.getBoundingClientRect();
return {
top: screenRect.top + (cursorY + 1) * cellHeight,
left: screenRect.left + cursorX * cellWidth,
};
} catch {
return { top: 0, left: 0 };
}
}, [terminalRef]);
const dismissSuggestions = useCallback(() => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
searchTimerRef.current = null;
}
if (
!showSuggestionsRef.current &&
suggestionsRef.current.length === 0 &&
selectedIndexRef.current === -1
) {
return;
}
showSuggestionsRef.current = false;
suggestionsRef.current = [];
selectedIndexRef.current = -1;
setSuggestions([]);
setSelectedIndex(-1);
setShowSuggestions(false);
}, []);
useEffect(() => {
if (!enabled) {
dismissSuggestions();
}
}, [enabled, dismissSuggestions]);
useEffect(() => {
return () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
};
}, []);
const triggerSearch = useCallback(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!enabledRef.current) {
dismissSuggestions();
return;
}
if (!canSuggestFromTracker(inputStateRef.current)) {
dismissSuggestions();
return;
}
searchTimerRef.current = setTimeout(async () => {
if (!enabledRef.current) {
dismissSuggestions();
return;
}
const pattern = getTrackedCommand(inputStateRef.current);
if (!pattern.trim() || !canSuggestFromTracker(inputStateRef.current)) {
dismissSuggestions();
return;
}
try {
// Parallel search across all suggestion providers.
// To add a new provider, append another invoke() call here.
const [historyResults, commandResults] = await Promise.all([
invoke<FuzzyResult[]>("fuzzy_search_history", { pattern, limit: 8 }),
invoke<FuzzyResult[]>("fuzzy_search_commands", { pattern, limit: 8 }),
]);
// Merge, sort by score descending, and cap total
const merged = [...historyResults, ...commandResults]
.sort((a, b) => b.score - a.score)
.slice(0, 12);
if (!enabledRef.current) {
dismissSuggestions();
return;
}
suggestionsRef.current = merged;
selectedIndexRef.current = -1;
showSuggestionsRef.current = merged.length > 0;
setSuggestions(merged);
setSelectedIndex(-1);
setShowSuggestions(merged.length > 0);
if (merged.length > 0) {
setCursorPosition(getCursorViewportPosition());
}
} catch {
// Ignore errors
}
}, 80);
}, [dismissSuggestions, getCursorViewportPosition, inputStateRef]);
const handleSelectSuggestion = useCallback(
(command: string) => {
applySuggestion(command, true);
dismissSuggestions();
terminalRef.current?.focus();
},
[applySuggestion, dismissSuggestions, terminalRef],
);
return {
suggestions,
selectedIndex,
setSelectedIndex,
showSuggestions,
cursorPosition,
suggestionsRef,
selectedIndexRef,
showSuggestionsRef,
searchTimerRef,
triggerSearch,
dismissSuggestions,
handleSelectSuggestion,
};
}