mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: string) => void;
|
||||
position: { top: number; left: number };
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
position,
|
||||
visible,
|
||||
}: CommandAutocompleteProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && containerRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-canvas border border-edge rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto thin-scrollbar"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-hover",
|
||||
index === selectedIndex && "bg-surface text-muted-foreground",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-edge bg-canvas/50 shrink-0">
|
||||
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CommandHistoryContextType {
|
||||
commandHistory: string[];
|
||||
isLoading: boolean;
|
||||
setCommandHistory: (history: string[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
onSelectCommand?: (command: string) => void;
|
||||
setOnSelectCommand: (callback: (command: string) => void) => void;
|
||||
onDeleteCommand?: (command: string) => void;
|
||||
setOnDeleteCommand: (callback: (command: string) => void) => void;
|
||||
openCommandHistory: () => void;
|
||||
setOpenCommandHistory: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const CommandHistoryContext = createContext<
|
||||
CommandHistoryContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [onSelectCommand, setOnSelectCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [onDeleteCommand, setOnDeleteCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [openCommandHistory, setOpenCommandHistory] = useState<
|
||||
(() => void) | undefined
|
||||
>(() => () => {});
|
||||
|
||||
const handleSetOnSelectCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnSelectCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOnDeleteCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnDeleteCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
|
||||
setOpenCommandHistory(() => callback);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandHistoryContext.Provider
|
||||
value={{
|
||||
commandHistory,
|
||||
isLoading,
|
||||
setCommandHistory,
|
||||
setIsLoading,
|
||||
onSelectCommand,
|
||||
setOnSelectCommand: handleSetOnSelectCommand,
|
||||
onDeleteCommand,
|
||||
setOnDeleteCommand: handleSetOnDeleteCommand,
|
||||
openCommandHistory: openCommandHistory || (() => {}),
|
||||
setOpenCommandHistory: handleSetOpenCommandHistory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CommandHistoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandHistory() {
|
||||
const context = useContext(CommandHistoryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCommandHistory must be used within a CommandHistoryProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getCommandHistory, saveCommandToHistory } from "@/main-axios.ts";
|
||||
|
||||
interface UseCommandHistoryOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface CommandHistoryResult {
|
||||
suggestions: string[];
|
||||
getSuggestions: (input: string) => string[];
|
||||
saveCommand: (command: string) => Promise<void>;
|
||||
clearSuggestions: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useCommandHistory({
|
||||
hostId,
|
||||
enabled = true,
|
||||
}: UseCommandHistoryOptions): CommandHistoryResult {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const historyCache = useRef<Map<number, string[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hostId) {
|
||||
setCommandHistory([]);
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = historyCache.current.get(hostId);
|
||||
if (cached) {
|
||||
setCommandHistory(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const history = await getCommandHistory(hostId);
|
||||
setCommandHistory(history);
|
||||
historyCache.current.set(hostId, history);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch command history:", error);
|
||||
setCommandHistory([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHistory();
|
||||
}, [hostId, enabled]);
|
||||
|
||||
const getSuggestions = useCallback(
|
||||
(input: string): string[] => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const matches = commandHistory.filter((cmd) =>
|
||||
cmd.startsWith(trimmedInput),
|
||||
);
|
||||
|
||||
const filtered = matches
|
||||
.filter((cmd) => cmd !== trimmedInput)
|
||||
.slice(0, 10);
|
||||
|
||||
setSuggestions(filtered);
|
||||
return filtered;
|
||||
},
|
||||
[commandHistory],
|
||||
);
|
||||
|
||||
const saveCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!enabled || !hostId || !command || command.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
|
||||
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommandToHistory(hostId, trimmedCommand);
|
||||
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
...prev.filter((c) => c !== trimmedCommand),
|
||||
];
|
||||
const limited = newHistory.slice(0, 500);
|
||||
historyCache.current.set(hostId, limited);
|
||||
return limited;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save command to history:", error);
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = [
|
||||
trimmedCommand,
|
||||
...prev.filter((c) => c !== trimmedCommand),
|
||||
];
|
||||
return newHistory.slice(0, 500);
|
||||
});
|
||||
}
|
||||
},
|
||||
[enabled, hostId, commandHistory],
|
||||
);
|
||||
|
||||
const clearSuggestions = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
getSuggestions,
|
||||
saveCommand,
|
||||
clearSuggestions,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { saveCommandToHistory } from "@/main-axios.ts";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
onCommandExecuted?: (command: string) => void;
|
||||
}
|
||||
|
||||
interface CommandTrackerResult {
|
||||
trackInput: (data: string) => void;
|
||||
getCurrentCommand: () => string;
|
||||
clearCurrentCommand: () => void;
|
||||
updateCurrentCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export function useCommandTracker({
|
||||
hostId,
|
||||
enabled = true,
|
||||
onCommandExecuted,
|
||||
}: UseCommandTrackerOptions): CommandTrackerResult {
|
||||
const currentCommandRef = useRef<string>("");
|
||||
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||
|
||||
const trackInput = useCallback(
|
||||
(data: string) => {
|
||||
if (!enabled || !hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data[i];
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
if (charCode === 27) {
|
||||
isInEscapeSequenceRef.current = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInEscapeSequenceRef.current) {
|
||||
if (
|
||||
(charCode >= 65 && charCode <= 90) ||
|
||||
(charCode >= 97 && charCode <= 122) ||
|
||||
charCode === 126
|
||||
) {
|
||||
isInEscapeSequenceRef.current = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 13 || charCode === 10) {
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
}
|
||||
}
|
||||
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 8 || charCode === 127) {
|
||||
if (currentCommandRef.current.length > 0) {
|
||||
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 3 || charCode === 4) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 21) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode >= 32 && charCode <= 126) {
|
||||
currentCommandRef.current += char;
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, hostId, onCommandExecuted],
|
||||
);
|
||||
|
||||
const getCurrentCommand = useCallback(() => {
|
||||
return currentCommandRef.current;
|
||||
}, []);
|
||||
|
||||
const clearCurrentCommand = useCallback(() => {
|
||||
currentCommandRef.current = "";
|
||||
}, []);
|
||||
|
||||
const updateCurrentCommand = useCallback((command: string) => {
|
||||
currentCommandRef.current = command;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackInput,
|
||||
getCurrentCommand,
|
||||
clearCurrentCommand,
|
||||
updateCurrentCommand,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user