feat: Add terminal command history tracking and autocomplete

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 <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-11-09 20:18:52 +08:00
parent c4c5be34f2
commit 49094bbadc
10 changed files with 1305 additions and 0 deletions

View File

@@ -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(
(

View File

@@ -322,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
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 {

View File

@@ -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`),
});

View File

@@ -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;

View File

@@ -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<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
style={{
top: `${position.top}px`,
left: `${position.left}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-dark-hover",
index === selectedIndex && "bg-blue-500/20 text-blue-400"
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {
// Optional: update selected index on hover
}}
>
{suggestion}
</div>
))}
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
Tab/Enter to complete to navigate Esc to close
</div>
</div>
);
}

View File

@@ -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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Command History
</DialogTitle>
</DialogHeader>
<div className="px-6 pb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => {
setSearchQuery("");
inputRef.current?.focus();
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
Loading history...
</div>
</div>
) : filteredCommands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20" />
<p>No commands found matching "{searchQuery}"</p>
</>
) : (
<>
<Clock className="h-12 w-12 mb-2 opacity-20" />
<p>No command history yet</p>
<p className="text-sm">Execute commands to build your history</p>
</>
)}
</div>
) : (
<div className="space-y-1">
{filteredCommands.map((command, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-4 py-2.5 rounded-md transition-colors group",
"font-mono text-sm flex items-center justify-between gap-2",
"hover:bg-accent",
index === selectedIndex && "bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50"
)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className="flex-1 cursor-pointer"
onClick={() => handleSelect(command)}
>
{command}
</span>
{onDeleteCommand && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
onDeleteCommand(command);
}}
title="Delete command"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<div className="px-6 py-3 border-t border-border bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded"></kbd> Navigate
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">Enter</kbd> Select
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">Esc</kbd> Close
</span>
</div>
<span>
{filteredCommands.length} command{filteredCommands.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<TerminalHandle, SSHTerminalProps>(
const isConnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(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<string[]>([]);
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0);
const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 });
const autocompleteHistory = useRef<string[]>([]);
const currentAutocompleteCommand = useRef<string>("");
// Refs for accessing current state in event handlers
const showAutocompleteRef = useRef(false);
const autocompleteSuggestionsRef = useRef<string[]>([]);
const autocompleteSelectedIndexRef = useRef(0);
// Command history dialog (Stage 2)
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
const [commandHistory, setCommandHistory] = useState<string[]>([]);
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<TerminalHandle, SSHTerminalProps>(
}),
);
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<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
};
}, [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<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<CommandHistoryDialog
open={showHistoryDialog}
onOpenChange={setShowHistoryDialog}
commands={commandHistory}
onSelectCommand={handleSelectCommand}
onDeleteCommand={handleDeleteCommand}
isLoading={isLoadingHistory}
/>
<CommandAutocomplete
visible={showAutocomplete}
suggestions={autocompleteSuggestions}
selectedIndex={autocompleteSelectedIndex}
position={autocompletePosition}
onSelect={handleAutocompleteSelect}
/>
{isConnecting && (
<div
className="absolute inset-0 flex items-center justify-center"

View File

@@ -0,0 +1,142 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
getCommandHistory,
saveCommandToHistory,
} from "@/ui/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;
}
/**
* Custom hook for managing command history and autocomplete suggestions
*/
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());
// 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,
};
}

View File

@@ -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<string>("");
const isInEscapeSequenceRef = useRef<boolean>(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,
};
}

View File

@@ -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<string[]> {
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");
}
}