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:
@@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js";
|
|||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes);
|
|||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
|
app.use("/terminal", terminalRoutes);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -322,6 +322,16 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
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 {
|
try {
|
||||||
|
|||||||
@@ -239,3 +239,17 @@ export const recentActivity = sqliteTable("recent_activity", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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`),
|
||||||
|
});
|
||||||
|
|||||||
221
src/backend/database/routes/terminal.ts
Normal file
221
src/backend/database/routes/terminal.ts
Normal 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;
|
||||||
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal file
67
src/ui/desktop/apps/terminal/CommandAutocomplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal file
225
src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
@@ -26,6 +27,10 @@ import {
|
|||||||
TERMINAL_FONTS,
|
TERMINAL_FONTS,
|
||||||
} from "@/constants/terminal-themes";
|
} from "@/constants/terminal-themes";
|
||||||
import type { TerminalConfig } from "@/types";
|
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 {
|
interface HostConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -122,6 +127,94 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const activityLoggedRef = useRef(false);
|
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 activityLoggingRef = useRef(false);
|
||||||
|
|
||||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||||
@@ -511,6 +604,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
|
// Track command input for history (Stage 1)
|
||||||
|
trackInput(data);
|
||||||
|
// Send input to server
|
||||||
ws.send(JSON.stringify({ type: "input", data }));
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -770,6 +866,86 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return "";
|
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(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !xtermRef.current) return;
|
if (!terminal || !xtermRef.current) return;
|
||||||
|
|
||||||
@@ -874,6 +1050,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||||
navigator.userAgent.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 (
|
if (
|
||||||
config.backspaceMode === "control-h" &&
|
config.backspaceMode === "control-h" &&
|
||||||
e.key === "Backspace" &&
|
e.key === "Backspace" &&
|
||||||
@@ -962,6 +1146,167 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
};
|
};
|
||||||
}, [xtermRef, terminal, hostConfig]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!terminal || !hostConfig || !visible) return;
|
if (!terminal || !hostConfig || !visible) return;
|
||||||
|
|
||||||
@@ -1088,6 +1433,23 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
backgroundColor={backgroundColor}
|
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 && (
|
{isConnecting && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
|||||||
142
src/ui/hooks/useCommandHistory.ts
Normal file
142
src/ui/hooks/useCommandHistory.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
144
src/ui/hooks/useCommandTracker.ts
Normal file
144
src/ui/hooks/useCommandTracker.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
// FILE MANAGER DATA
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2801,3 +2850,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
|
|||||||
throw handleApiError(error, "reset recent activity");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user