feat: add beta syntax highlighing to terminal
This commit is contained in:
338
src/lib/terminal-syntax-highlighter.ts
Normal file
338
src/lib/terminal-syntax-highlighter.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Syntax Highlighter
|
||||||
|
*
|
||||||
|
* Adds syntax highlighting to terminal output by injecting ANSI color codes
|
||||||
|
* for common patterns like commands, paths, IPs, log levels, and keywords.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Preserves existing ANSI codes from SSH output
|
||||||
|
* - Performance-optimized for streaming logs
|
||||||
|
* - Priority-based pattern matching to avoid overlaps
|
||||||
|
* - Configurable via localStorage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ANSI escape code constants
|
||||||
|
const ANSI_CODES = {
|
||||||
|
reset: "\x1b[0m",
|
||||||
|
colors: {
|
||||||
|
red: "\x1b[31m",
|
||||||
|
green: "\x1b[32m",
|
||||||
|
yellow: "\x1b[33m",
|
||||||
|
blue: "\x1b[34m",
|
||||||
|
magenta: "\x1b[35m",
|
||||||
|
cyan: "\x1b[36m",
|
||||||
|
white: "\x1b[37m",
|
||||||
|
brightBlack: "\x1b[90m", // Gray
|
||||||
|
brightRed: "\x1b[91m",
|
||||||
|
brightGreen: "\x1b[92m",
|
||||||
|
brightYellow: "\x1b[93m",
|
||||||
|
brightBlue: "\x1b[94m",
|
||||||
|
brightMagenta: "\x1b[95m",
|
||||||
|
brightCyan: "\x1b[96m",
|
||||||
|
brightWhite: "\x1b[97m",
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
bold: "\x1b[1m",
|
||||||
|
dim: "\x1b[2m",
|
||||||
|
italic: "\x1b[3m",
|
||||||
|
underline: "\x1b[4m",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Pattern definition interface
|
||||||
|
interface HighlightPattern {
|
||||||
|
name: string;
|
||||||
|
regex: RegExp;
|
||||||
|
ansiCode: string;
|
||||||
|
priority: number;
|
||||||
|
quickCheck?: string; // Optional fast string.includes() check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match result interface for tracking ranges
|
||||||
|
interface MatchResult {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
ansiCode: string;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const MAX_LINE_LENGTH = 5000; // Skip highlighting for very long lines
|
||||||
|
const MAX_ANSI_CODES = 10; // Skip if text has many ANSI codes (likely already colored/interactive app)
|
||||||
|
|
||||||
|
// Pattern definitions by category (pre-compiled)
|
||||||
|
// Based on SecureCRT proven patterns with strict boundaries
|
||||||
|
const PATTERNS: HighlightPattern[] = [
|
||||||
|
// Priority 1: IP Addresses (HIGHEST - from SecureCRT line 94)
|
||||||
|
// Matches: 192.168.1.1, 10.0.0.5, 127.0.0.1:8080
|
||||||
|
// WON'T match: dates like "2025" or "03:11:36"
|
||||||
|
{
|
||||||
|
name: "ipv4",
|
||||||
|
regex:
|
||||||
|
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||||
|
ansiCode: ANSI_CODES.colors.magenta,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 2: Log Levels - Error (bright red)
|
||||||
|
{
|
||||||
|
name: "log-error",
|
||||||
|
regex:
|
||||||
|
/\b(ERROR|FATAL|CRITICAL|FAIL(?:ED)?|denied|invalid|DENIED)\b|\[ERROR\]/gi,
|
||||||
|
ansiCode: ANSI_CODES.colors.brightRed,
|
||||||
|
priority: 9,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 3: Log Levels - Warning (yellow)
|
||||||
|
{
|
||||||
|
name: "log-warn",
|
||||||
|
regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi,
|
||||||
|
ansiCode: ANSI_CODES.colors.yellow,
|
||||||
|
priority: 9,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 4: Log Levels - Success (bright green)
|
||||||
|
{
|
||||||
|
name: "log-success",
|
||||||
|
regex:
|
||||||
|
/\b(SUCCESS|OK|PASS(?:ED)?|COMPLETE(?:D)?|connected|active|up|Up|UP|FULL)\b/gi,
|
||||||
|
ansiCode: ANSI_CODES.colors.brightGreen,
|
||||||
|
priority: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 5: URLs (must start with http/https)
|
||||||
|
{
|
||||||
|
name: "url",
|
||||||
|
regex: /https?:\/\/[^\s\])}]+/g,
|
||||||
|
ansiCode: `${ANSI_CODES.colors.blue}${ANSI_CODES.styles.underline}`,
|
||||||
|
priority: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 6: Absolute paths - STRICT (must have 2+ segments)
|
||||||
|
// Matches: /var/log/file.log, /home/user/docs
|
||||||
|
// WON'T match: /03, /2025, single segments
|
||||||
|
{
|
||||||
|
name: "path-absolute",
|
||||||
|
regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g,
|
||||||
|
ansiCode: ANSI_CODES.colors.cyan,
|
||||||
|
priority: 7,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 7: Home paths
|
||||||
|
{
|
||||||
|
name: "path-home",
|
||||||
|
regex: /~\/[a-zA-Z0-9_\-@./]+/g,
|
||||||
|
ansiCode: ANSI_CODES.colors.cyan,
|
||||||
|
priority: 7,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Priority 8: Other log levels
|
||||||
|
{
|
||||||
|
name: "log-info",
|
||||||
|
regex: /\bINFO\b|\[INFO\]/gi,
|
||||||
|
ansiCode: ANSI_CODES.colors.blue,
|
||||||
|
priority: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "log-debug",
|
||||||
|
regex: /\b(?:DEBUG|TRACE)\b|\[(?:DEBUG|TRACE)\]/gi,
|
||||||
|
ansiCode: ANSI_CODES.colors.brightBlack,
|
||||||
|
priority: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains existing ANSI escape sequences
|
||||||
|
*/
|
||||||
|
function hasExistingAnsiCodes(text: string): boolean {
|
||||||
|
// Count all ANSI escape sequences (not just CSI)
|
||||||
|
const ansiCount = (
|
||||||
|
text.match(
|
||||||
|
/\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
|
||||||
|
) || []
|
||||||
|
).length;
|
||||||
|
return ansiCount > MAX_ANSI_CODES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text appears to be incomplete (partial ANSI sequence at end)
|
||||||
|
*/
|
||||||
|
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||||
|
// Check if text ends with incomplete ANSI escape sequence
|
||||||
|
return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse text into segments: plain text and ANSI codes
|
||||||
|
*/
|
||||||
|
interface TextSegment {
|
||||||
|
isAnsi: boolean;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsiSegments(text: string): TextSegment[] {
|
||||||
|
const segments: TextSegment[] = [];
|
||||||
|
// More comprehensive ANSI regex - matches SGR (colors), cursor movement, erase sequences, etc.
|
||||||
|
const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = ansiRegex.exec(text)) !== null) {
|
||||||
|
// Plain text before ANSI code
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
segments.push({
|
||||||
|
isAnsi: false,
|
||||||
|
content: text.slice(lastIndex, match.index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSI code itself
|
||||||
|
segments.push({
|
||||||
|
isAnsi: true,
|
||||||
|
content: match[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = ansiRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining plain text
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
segments.push({
|
||||||
|
isAnsi: false,
|
||||||
|
content: text.slice(lastIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply highlights to plain text (no ANSI codes)
|
||||||
|
*/
|
||||||
|
function highlightPlainText(text: string): string {
|
||||||
|
// Skip very long lines for performance
|
||||||
|
if (text.length > MAX_LINE_LENGTH) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if text is empty or whitespace
|
||||||
|
if (!text.trim()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all matches for all patterns
|
||||||
|
const matches: MatchResult[] = [];
|
||||||
|
|
||||||
|
for (const pattern of PATTERNS) {
|
||||||
|
// Reset regex lastIndex
|
||||||
|
pattern.regex.lastIndex = 0;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.regex.exec(text)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
ansiCode: pattern.ansiCode,
|
||||||
|
priority: pattern.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no matches, return original text
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort matches by priority (descending) then by position
|
||||||
|
matches.sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) {
|
||||||
|
return b.priority - a.priority;
|
||||||
|
}
|
||||||
|
return a.start - b.start;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out overlapping matches (keep higher priority)
|
||||||
|
const appliedRanges: Array<{ start: number; end: number }> = [];
|
||||||
|
const finalMatches = matches.filter((match) => {
|
||||||
|
const overlaps = appliedRanges.some(
|
||||||
|
(range) =>
|
||||||
|
(match.start >= range.start && match.start < range.end) ||
|
||||||
|
(match.end > range.start && match.end <= range.end) ||
|
||||||
|
(match.start <= range.start && match.end >= range.end),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!overlaps) {
|
||||||
|
appliedRanges.push({ start: match.start, end: match.end });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply ANSI codes from end to start (to preserve indices)
|
||||||
|
let result = text;
|
||||||
|
finalMatches.reverse().forEach((match) => {
|
||||||
|
const before = result.slice(0, match.start);
|
||||||
|
const matched = result.slice(match.start, match.end);
|
||||||
|
const after = result.slice(match.end);
|
||||||
|
|
||||||
|
result = before + match.ansiCode + matched + ANSI_CODES.reset + after;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main export: Highlight terminal output text
|
||||||
|
*
|
||||||
|
* @param text - Terminal output text (may contain ANSI codes)
|
||||||
|
* @returns Text with syntax highlighting applied
|
||||||
|
*/
|
||||||
|
export function highlightTerminalOutput(text: string): string {
|
||||||
|
// Early exit for empty or whitespace-only text
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip highlighting if text has incomplete ANSI sequence (streaming chunk)
|
||||||
|
if (hasIncompleteAnsiSequence(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip highlighting if text already has many ANSI codes
|
||||||
|
// (likely already styled by SSH output or application)
|
||||||
|
if (hasExistingAnsiCodes(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse text into segments (plain text vs ANSI codes)
|
||||||
|
const segments = parseAnsiSegments(text);
|
||||||
|
|
||||||
|
// If no ANSI codes found, highlight entire text
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return highlightPlainText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight only plain text segments, preserve ANSI segments
|
||||||
|
const highlightedSegments = segments.map((segment) => {
|
||||||
|
if (segment.isAnsi) {
|
||||||
|
return segment.content; // Preserve existing ANSI codes
|
||||||
|
} else {
|
||||||
|
return highlightPlainText(segment.content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return highlightedSegments.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if syntax highlighting is enabled in localStorage
|
||||||
|
* Defaults to false if not set (opt-in behavior - BETA feature)
|
||||||
|
*/
|
||||||
|
export function isSyntaxHighlightingEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||||
|
} catch {
|
||||||
|
// If localStorage is not available, default to disabled
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} 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 { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||||
|
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
||||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||||
@@ -662,7 +663,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
if (msg.type === "data") {
|
if (msg.type === "data") {
|
||||||
if (typeof msg.data === "string") {
|
if (typeof msg.data === "string") {
|
||||||
terminal.write(msg.data);
|
// Apply syntax highlighting if enabled (BETA - defaults to false/off)
|
||||||
|
const syntaxHighlightingEnabled =
|
||||||
|
localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||||
|
|
||||||
|
const outputData = syntaxHighlightingEnabled
|
||||||
|
? highlightTerminalOutput(msg.data)
|
||||||
|
: msg.data;
|
||||||
|
|
||||||
|
terminal.write(outputData);
|
||||||
// Sudo password prompt detection
|
// Sudo password prompt detection
|
||||||
const sudoPasswordPattern =
|
const sudoPasswordPattern =
|
||||||
/(?:\[sudo\] password for \S+:|sudo: a password is required)/;
|
/(?:\[sudo\] password for \S+:|sudo: a password is required)/;
|
||||||
@@ -699,7 +708,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}, 15000);
|
}, 15000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
terminal.write(String(msg.data));
|
// Apply syntax highlighting to non-string data as well (BETA - defaults to false/off)
|
||||||
|
const syntaxHighlightingEnabled =
|
||||||
|
localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||||
|
|
||||||
|
const stringData = String(msg.data);
|
||||||
|
const outputData = syntaxHighlightingEnabled
|
||||||
|
? highlightTerminalOutput(stringData)
|
||||||
|
: stringData;
|
||||||
|
|
||||||
|
terminal.write(outputData);
|
||||||
}
|
}
|
||||||
} else if (msg.type === "error") {
|
} else if (msg.type === "error") {
|
||||||
const errorMessage = msg.message || t("terminal.unknownError");
|
const errorMessage = msg.message || t("terminal.unknownError");
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export function UserProfile({
|
|||||||
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
||||||
localStorage.getItem("commandAutocomplete") === "true",
|
localStorage.getItem("commandAutocomplete") === "true",
|
||||||
);
|
);
|
||||||
|
const [terminalSyntaxHighlighting, setTerminalSyntaxHighlighting] =
|
||||||
|
useState<boolean>(
|
||||||
|
() => localStorage.getItem("terminalSyntaxHighlighting") === "true",
|
||||||
|
);
|
||||||
const [defaultSnippetFoldersCollapsed, setDefaultSnippetFoldersCollapsed] =
|
const [defaultSnippetFoldersCollapsed, setDefaultSnippetFoldersCollapsed] =
|
||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||||
@@ -174,6 +178,12 @@ export function UserProfile({
|
|||||||
localStorage.setItem("commandAutocomplete", enabled.toString());
|
localStorage.setItem("commandAutocomplete", enabled.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTerminalSyntaxHighlightingToggle = (enabled: boolean) => {
|
||||||
|
setTerminalSyntaxHighlighting(enabled);
|
||||||
|
localStorage.setItem("terminalSyntaxHighlighting", enabled.toString());
|
||||||
|
window.dispatchEvent(new Event("terminalSyntaxHighlightingChanged"));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDefaultSnippetFoldersCollapsedToggle = (enabled: boolean) => {
|
const handleDefaultSnippetFoldersCollapsedToggle = (enabled: boolean) => {
|
||||||
setDefaultSnippetFoldersCollapsed(enabled);
|
setDefaultSnippetFoldersCollapsed(enabled);
|
||||||
localStorage.setItem("defaultSnippetFoldersCollapsed", enabled.toString());
|
localStorage.setItem("defaultSnippetFoldersCollapsed", enabled.toString());
|
||||||
@@ -485,6 +495,24 @@ export function UserProfile({
|
|||||||
onCheckedChange={handleCommandAutocompleteToggle}
|
onCheckedChange={handleCommandAutocompleteToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
Terminal Syntax Highlighting{" "}
|
||||||
|
<span className="text-xs text-yellow-500 font-semibold">
|
||||||
|
(BETA)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Automatically highlight commands, paths, IPs, and log
|
||||||
|
levels in terminal output
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={terminalSyntaxHighlighting}
|
||||||
|
onCheckedChange={handleTerminalSyntaxHighlightingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user