From 49094bbadce624fe23d6042c0e669606ebbce619 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 20:18:52 +0800 Subject: [PATCH] feat: Add terminal command history tracking and autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive command history system for SSH terminals: - Stage 1: Automatic command tracking with character-level input monitoring - Stage 2: Ctrl+R history search dialog with real-time filtering and keyboard navigation - Stage 3: Tab-based autocomplete with multi-match selection UI Key features: - Per-host command history stored in SQLite database - Smart positioning for autocomplete menu (auto-adjusts based on screen space) - Delete individual commands from history - Keyboard shortcuts: Ctrl+R (search), Tab (autocomplete), ↑↓ (navigate), Enter (select), Esc (close) Bug fixes: - Prevent double input by filtering keyup events (only handle keydown) - Use refs to avoid closure traps in event handlers - Real-time history updates without requiring terminal restart 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 10 + src/backend/database/db/schema.ts | 14 + src/backend/database/routes/terminal.ts | 221 +++++++++++ .../apps/terminal/CommandAutocomplete.tsx | 67 ++++ .../apps/terminal/CommandHistoryDialog.tsx | 225 +++++++++++ src/ui/desktop/apps/terminal/Terminal.tsx | 362 ++++++++++++++++++ src/ui/hooks/useCommandHistory.ts | 142 +++++++ src/ui/hooks/useCommandTracker.ts | 144 +++++++ src/ui/main-axios.ts | 118 ++++++ 10 files changed, 1305 insertions(+) create mode 100644 src/backend/database/routes/terminal.ts create mode 100644 src/ui/desktop/apps/terminal/CommandAutocomplete.tsx create mode 100644 src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx create mode 100644 src/ui/hooks/useCommandHistory.ts create mode 100644 src/ui/hooks/useCommandTracker.ts diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 83280beb..37a09592 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; +import terminalRoutes from "./routes/terminal.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); +app.use("/terminal", terminalRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index d735e6b7..5847882f 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -322,6 +322,16 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (host_id) REFERENCES ssh_data (id) ); + CREATE TABLE IF NOT EXISTS command_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + command TEXT NOT NULL, + executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); + `); try { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 2e5ed460..5aac5b3b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -239,3 +239,17 @@ export const recentActivity = sqliteTable("recent_activity", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const commandHistory = sqliteTable("command_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts new file mode 100644 index 00000000..f07366da --- /dev/null +++ b/src/backend/database/routes/terminal.ts @@ -0,0 +1,221 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express from "express"; +import { db } from "../db/index.js"; +import { commandHistory } from "../db/schema.js"; +import { eq, and, desc, sql } from "drizzle-orm"; +import type { Request, Response } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +const router = express.Router(); + +function isNonEmptyString(val: unknown): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Save command to history +// POST /terminal/command_history +router.post( + "/command_history", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if ( + !isNonEmptyString(userId) || + !hostId || + !isNonEmptyString(command) + ) { + authLogger.warn("Invalid command history save request", { + operation: "command_history_save", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const insertData = { + userId, + hostId: parseInt(hostId, 10), + command: command.trim(), + }; + + const result = await db.insert(commandHistory).values(insertData).returning(); + + authLogger.info(`Command saved to history for host ${hostId}`, { + operation: "command_history_save_success", + userId, + hostId: parseInt(hostId, 10), + }); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to save command to history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to save command", + }); + } + }, +); + +// Get command history for a specific host +// GET /terminal/command_history/:hostId +router.get( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history fetch request", { + userId, + hostId: hostIdNum, + }); + return res.status(400).json({ error: "Invalid request parameters" }); + } + + try { + // Get unique commands for this host, ordered by most recent + // Use DISTINCT to avoid duplicates, but keep the most recent occurrence + const result = await db + .selectDistinct({ command: commandHistory.command }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum) + ) + ) + .orderBy(desc(commandHistory.executedAt)) + .limit(500); // Limit to last 500 unique commands + + // Further deduplicate in case DISTINCT didn't work perfectly + const uniqueCommands = Array.from( + new Set(result.map((r) => r.command)) + ); + + authLogger.info(`Fetched command history for host ${hostId}`, { + operation: "command_history_fetch_success", + userId, + hostId: hostIdNum, + count: uniqueCommands.length, + }); + + res.json(uniqueCommands); + } catch (err) { + authLogger.error("Failed to fetch command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch history", + }); + } + }, +); + +// Delete a specific command from history +// POST /terminal/command_history/delete +router.post( + "/command_history/delete", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if ( + !isNonEmptyString(userId) || + !hostId || + !isNonEmptyString(command) + ) { + authLogger.warn("Invalid command delete request", { + operation: "command_history_delete", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const hostIdNum = parseInt(hostId, 10); + + // Delete all instances of this command for this user and host + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum), + eq(commandHistory.command, command.trim()) + ) + ); + + authLogger.info(`Command deleted from history for host ${hostId}`, { + operation: "command_history_delete_success", + userId, + hostId: hostIdNum, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete command from history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete command", + }); + } + }, +); + +// Clear command history for a specific host (optional feature) +// DELETE /terminal/command_history/:hostId +router.delete( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history clear request"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum) + ) + ); + + authLogger.success(`Command history cleared for host ${hostId}`, { + operation: "command_history_clear_success", + userId, + hostId: hostIdNum, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to clear command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to clear history", + }); + } + }, +); + +export default router; diff --git a/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx new file mode 100644 index 00000000..1828ab72 --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +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(null); + const selectedRef = useRef(null); + + // Scroll selected item into view + useEffect(() => { + if (selectedRef.current && containerRef.current) { + selectedRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + + if (!visible || suggestions.length === 0) { + return null; + } + + return ( +
+ {suggestions.map((suggestion, index) => ( +
onSelect(suggestion)} + onMouseEnter={() => { + // Optional: update selected index on hover + }} + > + {suggestion} +
+ ))} +
+ Tab/Enter to complete • ↑↓ to navigate • Esc to close +
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx new file mode 100644 index 00000000..644f950e --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Search, Clock, X, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CommandHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + commands: string[]; + onSelectCommand: (command: string) => void; + onDeleteCommand?: (command: string) => void; + isLoading?: boolean; +} + +export function CommandHistoryDialog({ + open, + onOpenChange, + commands, + onSelectCommand, + onDeleteCommand, + isLoading = false, +}: CommandHistoryDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const selectedRef = useRef(null); + + // Filter commands based on search query + const filteredCommands = searchQuery + ? commands.filter((cmd) => + cmd.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : commands; + + // Reset state when dialog opens/closes + useEffect(() => { + if (open) { + setSearchQuery(""); + setSelectedIndex(0); + // Focus search input + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // Scroll selected item into view + useEffect(() => { + if (selectedRef.current && listRef.current) { + selectedRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filteredCommands.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredCommands.length - 1 ? prev + 1 : prev + ); + break; + + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + break; + + case "Enter": + e.preventDefault(); + if (filteredCommands[selectedIndex]) { + onSelectCommand(filteredCommands[selectedIndex]); + onOpenChange(false); + } + break; + + case "Escape": + e.preventDefault(); + onOpenChange(false); + break; + } + }; + + const handleSelect = (command: string) => { + onSelectCommand(command); + onOpenChange(false); + }; + + return ( + + + + + + Command History + + + +
+
+ + { + setSearchQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+
+ + + {isLoading ? ( +
+
+
+ Loading history... +
+
+ ) : filteredCommands.length === 0 ? ( +
+ {searchQuery ? ( + <> + +

No commands found matching "{searchQuery}"

+ + ) : ( + <> + +

No command history yet

+

Execute commands to build your history

+ + )} +
+ ) : ( +
+ {filteredCommands.map((command, index) => ( +
setSelectedIndex(index)} + > + handleSelect(command)} + > + {command} + + {onDeleteCommand && ( + + )} +
+ ))} +
+ )} +
+ +
+
+
+ + ↑↓ Navigate + + + Enter Select + + + Esc Close + +
+ + {filteredCommands.length} command{filteredCommands.length !== 1 ? "s" : ""} + +
+
+
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 02b61b80..8b5d5eff 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -4,6 +4,7 @@ import { useState, useImperativeHandle, forwardRef, + useCallback, } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; @@ -26,6 +27,10 @@ import { TERMINAL_FONTS, } from "@/constants/terminal-themes"; import type { TerminalConfig } from "@/types"; +import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; +import { useCommandHistory } from "@/ui/hooks/useCommandHistory"; +import { CommandHistoryDialog } from "./CommandHistoryDialog"; +import { CommandAutocomplete } from "./CommandAutocomplete"; interface HostConfig { id?: number; @@ -122,6 +127,94 @@ export const Terminal = forwardRef( const isConnectingRef = useRef(false); const connectionTimeoutRef = useRef(null); const activityLoggedRef = useRef(false); + const keyHandlerAttachedRef = useRef(false); + + // Command history tracking (Stage 1) + const { trackInput, getCurrentCommand, updateCurrentCommand } = useCommandTracker({ + hostId: hostConfig.id, + enabled: true, + onCommandExecuted: (command) => { + // Add to autocomplete history (Stage 3) + if (!autocompleteHistory.current.includes(command)) { + autocompleteHistory.current = [command, ...autocompleteHistory.current]; + } + }, + }); + + // Create refs for callbacks to avoid triggering useEffect re-runs + const getCurrentCommandRef = useRef(getCurrentCommand); + const updateCurrentCommandRef = useRef(updateCurrentCommand); + + useEffect(() => { + getCurrentCommandRef.current = getCurrentCommand; + updateCurrentCommandRef.current = updateCurrentCommand; + }, [getCurrentCommand, updateCurrentCommand]); + + // Real-time autocomplete (Stage 3) + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0); + const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }); + const autocompleteHistory = useRef([]); + const currentAutocompleteCommand = useRef(""); + + // Refs for accessing current state in event handlers + const showAutocompleteRef = useRef(false); + const autocompleteSuggestionsRef = useRef([]); + const autocompleteSelectedIndexRef = useRef(0); + + // Command history dialog (Stage 2) + const [showHistoryDialog, setShowHistoryDialog] = useState(false); + const [commandHistory, setCommandHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // Load command history when dialog opens + useEffect(() => { + if (showHistoryDialog && hostConfig.id) { + setIsLoadingHistory(true); + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + setCommandHistory(history); + }) + .catch((error) => { + console.error("Failed to load command history:", error); + setCommandHistory([]); + }) + .finally(() => { + setIsLoadingHistory(false); + }); + } + }, [showHistoryDialog, hostConfig.id]); + + // Load command history for autocomplete on mount (Stage 3) + useEffect(() => { + if (hostConfig.id) { + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + autocompleteHistory.current = history; + }) + .catch((error) => { + console.error("Failed to load autocomplete history:", error); + autocompleteHistory.current = []; + }); + } + }, [hostConfig.id]); + + // Sync autocomplete state to refs for event handlers + useEffect(() => { + showAutocompleteRef.current = showAutocomplete; + }, [showAutocomplete]); + + useEffect(() => { + autocompleteSuggestionsRef.current = autocompleteSuggestions; + }, [autocompleteSuggestions]); + + useEffect(() => { + autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; + }, [autocompleteSelectedIndex]); + const activityLoggingRef = useRef(false); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -511,6 +604,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { + // Track command input for history (Stage 1) + trackInput(data); + // Send input to server ws.send(JSON.stringify({ type: "input", data })); }); @@ -770,6 +866,86 @@ export const Terminal = forwardRef( return ""; } + // Handle command selection from history dialog (Stage 2) + const handleSelectCommand = useCallback( + (command: string) => { + if (!terminal || !webSocketRef.current) return; + + // Send the command to the terminal + // Simulate typing the command character by character + for (const char of command) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + // Return focus to terminal after selecting command + setTimeout(() => { + terminal.focus(); + }, 100); + }, + [terminal] + ); + + // Handle autocomplete selection (mouse click) + const handleAutocompleteSelect = useCallback( + (selectedCommand: string) => { + if (!webSocketRef.current) return; + + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + // Update current command tracker + updateCurrentCommand(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + // Return focus to terminal + setTimeout(() => { + terminal?.focus(); + }, 50); + + console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`); + }, + [terminal, updateCurrentCommand] + ); + + // Handle command deletion from history dialog + const handleDeleteCommand = useCallback( + async (command: string) => { + if (!hostConfig.id) return; + + try { + // Call API to delete command + const { deleteCommandFromHistory } = await import("@/ui/main-axios.ts"); + await deleteCommandFromHistory(hostConfig.id, command); + + // Update local state + setCommandHistory((prev) => prev.filter((cmd) => cmd !== command)); + + // Update autocomplete history + autocompleteHistory.current = autocompleteHistory.current.filter( + (cmd) => cmd !== command + ); + + console.log(`[Terminal] Command deleted from history: ${command}`); + } catch (error) { + console.error("Failed to delete command from history:", error); + } + }, + [hostConfig.id] + ); + useEffect(() => { if (!terminal || !xtermRef.current) return; @@ -874,6 +1050,14 @@ export const Terminal = forwardRef( navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + // Handle Ctrl+R for command history (Stage 2) + if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + setShowHistoryDialog(true); + return false; + } + if ( config.backspaceMode === "control-h" && e.key === "Backspace" && @@ -962,6 +1146,167 @@ export const Terminal = forwardRef( }; }, [xtermRef, terminal, hostConfig]); + // Register keyboard handler for autocomplete (Stage 3) + // Registered only once when terminal is created + useEffect(() => { + if (!terminal) return; + + const handleCustomKey = (e: KeyboardEvent): boolean => { + // Only handle keydown events, ignore keyup to prevent double triggering + if (e.type !== 'keydown') { + return true; + } + + // If autocomplete is showing, handle keys specially + if (showAutocompleteRef.current) { + // Handle Escape to close autocomplete + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return false; + } + + // Handle Arrow keys for autocomplete navigation + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + + if (e.key === "ArrowDown") { + const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + } else if (e.key === "ArrowUp") { + const newIndex = currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; + setAutocompleteSelectedIndex(newIndex); + } + return false; + } + + // Handle Enter to confirm autocomplete selection + if (e.key === "Enter" && autocompleteSuggestionsRef.current.length > 0) { + e.preventDefault(); + e.stopPropagation(); + + const selectedCommand = autocompleteSuggestionsRef.current[autocompleteSelectedIndexRef.current]; + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + if (webSocketRef.current?.readyState === 1) { + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + } + + // Update current command tracker + updateCurrentCommandRef.current(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + return false; + } + + // Handle Tab to cycle through suggestions + if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + return false; + } + + // For any other key while autocomplete is showing, close it and let key through + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return true; + } + + // Handle Tab for autocomplete (when autocomplete is not showing) + if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + + const currentCmd = getCurrentCommandRef.current().trim(); + if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { + // Filter commands that start with current input + const matches = autocompleteHistory.current + .filter(cmd => + cmd.startsWith(currentCmd) && + cmd !== currentCmd && + cmd.length > currentCmd.length + ) + .slice(0, 10); // Show up to 10 matches + + if (matches.length === 1) { + // Only one match - auto-complete directly + const completedCommand = matches[0]; + const completion = completedCommand.substring(currentCmd.length); + + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + updateCurrentCommandRef.current(completedCommand); + } else if (matches.length > 1) { + // Multiple matches - show selection list + currentAutocompleteCommand.current = currentCmd; + setAutocompleteSuggestions(matches); + setAutocompleteSelectedIndex(0); + + // Calculate position (below or above cursor based on available space) + const cursorY = terminal.buffer.active.cursorY; + const cursorX = terminal.buffer.active.cursorX; + const rect = xtermRef.current?.getBoundingClientRect(); + + if (rect) { + const cellHeight = terminal.rows > 0 ? rect.height / terminal.rows : 20; + const cellWidth = terminal.cols > 0 ? rect.width / terminal.cols : 10; + + // Estimate autocomplete menu height (max-h-[240px] from component) + const menuHeight = 240; + const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; + const spaceBelow = window.innerHeight - cursorBottomY; + const spaceAbove = rect.top + cursorY * cellHeight; + + // Show above cursor if not enough space below + const showAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow; + + setAutocompletePosition({ + top: showAbove + ? rect.top + cursorY * cellHeight - menuHeight + : cursorBottomY, + left: rect.left + cursorX * cellWidth, + }); + } + + setShowAutocomplete(true); + } + } + return false; // Prevent default Tab behavior + } + + // Let terminal handle all other keys + return true; + }; + + terminal.attachCustomKeyEventHandler(handleCustomKey); + }, [terminal]); + useEffect(() => { if (!terminal || !hostConfig || !visible) return; @@ -1088,6 +1433,23 @@ export const Terminal = forwardRef( backgroundColor={backgroundColor} /> + + + + {isConnecting && (
string[]; + saveCommand: (command: string) => Promise; + clearSuggestions: () => void; + isLoading: boolean; +} + +/** + * Custom hook for managing command history and autocomplete suggestions + */ +export function useCommandHistory({ + hostId, + enabled = true, +}: UseCommandHistoryOptions): CommandHistoryResult { + const [commandHistory, setCommandHistory] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const historyCache = useRef>(new Map()); + + // Fetch command history when hostId changes + useEffect(() => { + if (!enabled || !hostId) { + setCommandHistory([]); + setSuggestions([]); + return; + } + + // Check cache first + const cached = historyCache.current.get(hostId); + if (cached) { + setCommandHistory(cached); + return; + } + + // Fetch from server + 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]); + + /** + * Get command suggestions based on current input + */ + 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) + ); + + // Return up to 10 suggestions, excluding exact matches + const filtered = matches.filter((cmd) => cmd !== trimmedInput).slice(0, 10); + + setSuggestions(filtered); + return filtered; + }, + [commandHistory] + ); + + /** + * Save a command to history + */ + const saveCommand = useCallback( + async (command: string) => { + if (!enabled || !hostId || !command || command.trim().length === 0) { + return; + } + + const trimmedCommand = command.trim(); + + // Skip if it's the same as the last command + if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) { + return; + } + + try { + // Save to server + await saveCommandToHistory(hostId, trimmedCommand); + + // Update local state - add to beginning + setCommandHistory((prev) => { + const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)]; + // Keep max 500 commands in memory + const limited = newHistory.slice(0, 500); + historyCache.current.set(hostId, limited); + return limited; + }); + } catch (error) { + console.error("Failed to save command to history:", error); + // Still update local state even if server save fails + setCommandHistory((prev) => { + const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)]; + return newHistory.slice(0, 500); + }); + } + }, + [enabled, hostId, commandHistory] + ); + + /** + * Clear current suggestions + */ + const clearSuggestions = useCallback(() => { + setSuggestions([]); + }, []); + + return { + suggestions, + getSuggestions, + saveCommand, + clearSuggestions, + isLoading, + }; +} diff --git a/src/ui/hooks/useCommandTracker.ts b/src/ui/hooks/useCommandTracker.ts new file mode 100644 index 00000000..fe7302a3 --- /dev/null +++ b/src/ui/hooks/useCommandTracker.ts @@ -0,0 +1,144 @@ +import { useRef, useCallback } from "react"; +import { saveCommandToHistory } from "@/ui/main-axios.ts"; + +interface UseCommandTrackerOptions { + hostId?: number; + enabled?: boolean; + onCommandExecuted?: (command: string) => void; +} + +interface CommandTrackerResult { + trackInput: (data: string) => void; + getCurrentCommand: () => string; + clearCurrentCommand: () => void; + updateCurrentCommand: (command: string) => void; +} + +/** + * Hook to track terminal input and save executed commands to history + * Works with SSH terminals by monitoring input data + */ +export function useCommandTracker({ + hostId, + enabled = true, + onCommandExecuted, +}: UseCommandTrackerOptions): CommandTrackerResult { + const currentCommandRef = useRef(""); + const isInEscapeSequenceRef = useRef(false); + + /** + * Track input data and detect command execution + */ + const trackInput = useCallback( + (data: string) => { + if (!enabled || !hostId) { + return; + } + + // Handle each character + for (let i = 0; i < data.length; i++) { + const char = data[i]; + const charCode = char.charCodeAt(0); + + // Detect escape sequences (e.g., arrow keys, function keys) + if (charCode === 27) { + // ESC + isInEscapeSequenceRef.current = true; + continue; + } + + // Skip characters that are part of escape sequences + if (isInEscapeSequenceRef.current) { + // Common escape sequence endings + if ( + (charCode >= 65 && charCode <= 90) || // A-Z + (charCode >= 97 && charCode <= 122) || // a-z + charCode === 126 // ~ + ) { + isInEscapeSequenceRef.current = false; + } + continue; + } + + // Handle Enter key (CR or LF) + if (charCode === 13 || charCode === 10) { + // \r or \n + const command = currentCommandRef.current.trim(); + + // Save non-empty commands + if (command.length > 0) { + // Save to history (async, don't wait) + saveCommandToHistory(hostId, command).catch((error) => { + console.error("Failed to save command to history:", error); + }); + + // Callback for external handling + if (onCommandExecuted) { + onCommandExecuted(command); + } + } + + // Clear current command + currentCommandRef.current = ""; + continue; + } + + // Handle Backspace/Delete + if (charCode === 8 || charCode === 127) { + // Backspace or DEL + if (currentCommandRef.current.length > 0) { + currentCommandRef.current = currentCommandRef.current.slice(0, -1); + } + continue; + } + + // Handle Ctrl+C, Ctrl+D, etc. - clear current command + if (charCode === 3 || charCode === 4) { + currentCommandRef.current = ""; + continue; + } + + // Handle Ctrl+U (clear line) - common in terminals + if (charCode === 21) { + currentCommandRef.current = ""; + continue; + } + + // Add printable characters to current command + if (charCode >= 32 && charCode <= 126) { + // Printable ASCII + currentCommandRef.current += char; + } + } + }, + [enabled, hostId, onCommandExecuted] + ); + + /** + * Get the current command being typed + */ + const getCurrentCommand = useCallback(() => { + return currentCommandRef.current; + }, []); + + /** + * Clear the current command buffer + */ + const clearCurrentCommand = useCallback(() => { + currentCommandRef.current = ""; + }, []); + + /** + * Update the current command buffer (used for autocomplete) + */ + const updateCurrentCommand = useCallback((command: string) => { + currentCommandRef.current = command; + }, []); + + return { + trackInput, + getCurrentCommand, + clearCurrentCommand, + updateCurrentCommand, + }; +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index ae7fc925..5b0c879d 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1606,6 +1606,55 @@ export async function extractSSHArchive( } } +export async function compressSSHFiles( + sessionId: string, + paths: string[], + archiveName: string, + format?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; archivePath: string }> { + try { + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/compressFiles", { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }); + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath: response.data.archivePath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to compress files", error, { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + }); + handleApiError(error, "compress files"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ @@ -2801,3 +2850,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> { throw handleApiError(error, "reset recent activity"); } } + +// ============================================================================ +// COMMAND HISTORY API +// ============================================================================ + +/** + * Save a command to history for a specific host + */ +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise<{ id: number; command: string; executedAt: string }> { + try { + const response = await authApi.post("/terminal/command_history", { + hostId, + command, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "save command to history"); + } +} + +/** + * Get command history for a specific host + * Returns array of unique commands ordered by most recent + */ +export async function getCommandHistory( + hostId: number, +): Promise { + try { + const response = await authApi.get(`/terminal/command_history/${hostId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch command history"); + } +} + +/** + * Delete a specific command from history + */ +export async function deleteCommandFromHistory( + hostId: number, + command: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.post("/terminal/command_history/delete", { + hostId, + command, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete command from history"); + } +} + +/** + * Clear command history for a specific host (optional feature) + */ +export async function clearCommandHistory( + hostId: number, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete(`/terminal/command_history/${hostId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "clear command history"); + } +}