[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
349
components/LogView.tsx
Normal file
349
components/LogView.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { FileText, Download, Palette, X } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import { applyCustomAccentToTerminalTheme } from "../domain/terminalAppearance";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
interface LogViewProps {
|
||||
log: ConnectionLog;
|
||||
defaultTerminalTheme: TerminalTheme;
|
||||
defaultFontSize: number;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
log,
|
||||
defaultTerminalTheme,
|
||||
defaultFontSize,
|
||||
isVisible,
|
||||
onClose,
|
||||
onUpdateLog,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
// Leaf accent: published defaultTerminalTheme is the stable base catalog
|
||||
// theme; apply live custom accent here so log replay matches terminal chrome
|
||||
// without thrashing AppShell on every HSL tick.
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
|| customThemes.some((theme) => theme.id === log.themeId);
|
||||
return exists ? log.themeId : undefined;
|
||||
}, [customThemes, log.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (log.themeId && !explicitThemeId) {
|
||||
onUpdateLog(log.id, { themeId: undefined });
|
||||
}
|
||||
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
const baseTheme = explicitThemeId
|
||||
? (TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme)
|
||||
: defaultTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}, [accentMode, customAccent, customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
// Format date for display
|
||||
const formattedDate = useMemo(() => {
|
||||
const date = new Date(log.startTime);
|
||||
return date.toLocaleString(resolvedLocale || undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}, [log.startTime, resolvedLocale]);
|
||||
|
||||
// Handle theme change
|
||||
const handleThemeChange = useCallback((themeId: string) => {
|
||||
onUpdateLog(log.id, { themeId });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeModalOpen) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}, [themeModalOpen]);
|
||||
|
||||
// Handle font size change
|
||||
const handleFontSizeChange = useCallback((fontSize: number) => {
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!log.terminalData || isExporting) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.exportSessionLog) {
|
||||
await bridge.exportSessionLog({
|
||||
terminalData: log.terminalData,
|
||||
hostLabel: log.hostLabel,
|
||||
hostname: log.hostname,
|
||||
startTime: log.startTime,
|
||||
format: 'txt',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export session log:', err);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isVisible) return;
|
||||
|
||||
// Create terminal
|
||||
const term = new XTerm({
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", Monaco, Menlo, monospace',
|
||||
fontSize: currentFontSize,
|
||||
cursorBlink: false,
|
||||
cursorStyle: "underline",
|
||||
allowProposedApi: true,
|
||||
disableStdin: true, // Read-only mode
|
||||
theme: currentTheme.colors,
|
||||
scrollback: 10000,
|
||||
});
|
||||
|
||||
termRef.current = term;
|
||||
|
||||
// Create fit addon
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Open terminal
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Try to load WebGL addon for better performance
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, canvas renderer will be used
|
||||
}
|
||||
|
||||
// Fit terminal
|
||||
const initialFitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Write terminal data if available
|
||||
if (log.terminalData) {
|
||||
term.write(log.terminalData);
|
||||
} else {
|
||||
// No terminal data available
|
||||
term.writeln("\x1b[2m--- No terminal data captured for this session ---\x1b[0m");
|
||||
term.writeln("");
|
||||
term.writeln(`\x1b[36mHost:\x1b[0m ${log.hostname}`);
|
||||
term.writeln(`\x1b[36mUser:\x1b[0m ${log.username}`);
|
||||
term.writeln(`\x1b[36mProtocol:\x1b[0m ${log.protocol}`);
|
||||
term.writeln(`\x1b[36mTime:\x1b[0m ${formattedDate}`);
|
||||
if (log.endTime) {
|
||||
const duration = Math.round((log.endTime - log.startTime) / 1000);
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
term.writeln(`\x1b[36mDuration:\x1b[0m ${minutes}m ${seconds}s`);
|
||||
}
|
||||
}
|
||||
|
||||
setIsReady(true);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
clearTimeout(initialFitTimer);
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
setIsReady(false);
|
||||
};
|
||||
// Only re-create terminal when visibility or terminalData changes
|
||||
// Theme and font size updates are handled separately
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible, log.id, log.terminalData]);
|
||||
|
||||
// Update theme instantly without recreating terminal
|
||||
useEffect(() => {
|
||||
if (termRef.current && isReady) {
|
||||
termRef.current.options.theme = currentTheme.colors;
|
||||
}
|
||||
}, [currentTheme, isReady]);
|
||||
|
||||
// Update font size instantly without recreating terminal
|
||||
useEffect(() => {
|
||||
if (!termRef.current || !isReady) return;
|
||||
termRef.current.options.fontSize = currentFontSize;
|
||||
// Refit after font size change
|
||||
const refitTimer = setTimeout(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 10);
|
||||
return () => clearTimeout(refitTimer);
|
||||
}, [currentFontSize, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
if (containerRef.current?.parentElement) {
|
||||
resizeObserver.observe(containerRef.current.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
|
||||
isLocal
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-blue-500/10 text-blue-500"
|
||||
)}
|
||||
>
|
||||
<FileText size={14} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<div className="min-w-0 text-sm font-medium leading-5 truncate">
|
||||
{isLocal ? t("logs.localTerminal") : log.hostname}
|
||||
</div>
|
||||
<div className="text-xs leading-4 truncate text-muted-foreground">
|
||||
{formattedDate} • {log.localUsername}@{log.localHostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-7 shrink-0 items-center gap-1.5">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Theme & font customization button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
</Button>
|
||||
|
||||
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
|
||||
{t("logView.readOnly")}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal container */}
|
||||
<div
|
||||
className="flex-1 overflow-hidden p-2"
|
||||
style={{ backgroundColor: currentTheme?.colors?.background || '#000000' }}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Theme Customize Modal */}
|
||||
<ThemeCustomizeModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
currentThemeId={explicitThemeId}
|
||||
displayThemeId={currentTheme.id}
|
||||
currentFontSize={currentFontSize}
|
||||
onThemeChange={handleThemeChange}
|
||||
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onPreviewThemeChange={setPreviewTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoization comparison
|
||||
const logViewAreEqual = (prev: LogViewProps, next: LogViewProps): boolean => {
|
||||
return (
|
||||
prev.log.id === next.log.id &&
|
||||
prev.log.themeId === next.log.themeId &&
|
||||
prev.log.fontSize === next.log.fontSize &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
prev.defaultFontSize === next.defaultFontSize &&
|
||||
prev.defaultTerminalTheme.id === next.defaultTerminalTheme.id
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LogViewComponent, logViewAreEqual);
|
||||
Reference in New Issue
Block a user