mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
322 lines
9.6 KiB
Svelte
322 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type } from 'lucide-svelte';
|
|
import * as Select from '$lib/components/ui/select';
|
|
import { themeStore } from '$lib/stores/theme';
|
|
import { getMonospaceFont } from '$lib/themes';
|
|
|
|
interface Props {
|
|
logs: string;
|
|
containerName: string;
|
|
loading?: boolean;
|
|
autoRefresh?: boolean;
|
|
autoScroll?: boolean;
|
|
onRefresh?: () => void;
|
|
onAutoRefreshChange?: (value: boolean) => void;
|
|
onAutoScrollChange?: (value: boolean) => void;
|
|
class?: string;
|
|
}
|
|
|
|
let {
|
|
logs,
|
|
containerName,
|
|
loading = false,
|
|
autoRefresh = true,
|
|
autoScroll = true,
|
|
onRefresh,
|
|
onAutoRefreshChange,
|
|
onAutoScrollChange,
|
|
class: className = ''
|
|
}: Props = $props();
|
|
|
|
let logsRef: HTMLDivElement;
|
|
let wordWrap = $state(true);
|
|
let fontSize = $state(12);
|
|
|
|
// Search state
|
|
let logSearchActive = $state(false);
|
|
let logSearchQuery = $state('');
|
|
let currentMatchIndex = $state(0);
|
|
let matchCount = $state(0);
|
|
let logSearchInputRef: HTMLInputElement;
|
|
|
|
const fontSizeOptions = [10, 12, 14, 16];
|
|
|
|
// Get terminal font family from theme preferences
|
|
let terminalFontFamily = $derived(() => {
|
|
const fontMeta = getMonospaceFont($themeStore.terminalFont);
|
|
return fontMeta?.family || 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
|
|
});
|
|
|
|
// Auto-scroll when logs change
|
|
$effect(() => {
|
|
if (autoScroll && logsRef && logs) {
|
|
setTimeout(() => {
|
|
logsRef.scrollTop = logsRef.scrollHeight;
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
// Copy logs to clipboard
|
|
async function copyLogs() {
|
|
if (logs) {
|
|
try {
|
|
await navigator.clipboard.writeText(logs);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Download logs as txt file
|
|
function downloadLogs() {
|
|
if (logs && containerName) {
|
|
const blob = new Blob([logs], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${containerName}-logs.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
// Search functions
|
|
function toggleLogSearch() {
|
|
logSearchActive = !logSearchActive;
|
|
if (logSearchActive) {
|
|
setTimeout(() => logSearchInputRef?.focus(), 50);
|
|
} else {
|
|
logSearchQuery = '';
|
|
currentMatchIndex = 0;
|
|
matchCount = 0;
|
|
}
|
|
}
|
|
|
|
function closeLogSearch() {
|
|
logSearchActive = false;
|
|
logSearchQuery = '';
|
|
currentMatchIndex = 0;
|
|
matchCount = 0;
|
|
}
|
|
|
|
function navigateMatch(direction: 'prev' | 'next') {
|
|
if (!logsRef || matchCount === 0) return;
|
|
|
|
const matches = logsRef.querySelectorAll('.search-match');
|
|
if (matches.length === 0) return;
|
|
|
|
matches[currentMatchIndex]?.classList.remove('current-match');
|
|
|
|
if (direction === 'next') {
|
|
currentMatchIndex = (currentMatchIndex + 1) % matches.length;
|
|
} else {
|
|
currentMatchIndex = (currentMatchIndex - 1 + matches.length) % matches.length;
|
|
}
|
|
|
|
const currentEl = matches[currentMatchIndex];
|
|
if (currentEl) {
|
|
currentEl.classList.add('current-match');
|
|
currentEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
|
|
function handleLogSearchKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
navigateMatch('prev');
|
|
} else {
|
|
navigateMatch('next');
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
closeLogSearch();
|
|
}
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Highlighted logs with search matches
|
|
let highlightedLogs = $derived(() => {
|
|
const escaped = escapeHtml(logs || '');
|
|
if (!logSearchQuery.trim()) return escaped;
|
|
|
|
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const escapedQuery = escapeHtml(query);
|
|
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
|
return escaped.replace(regex, '<mark class="search-match">$1</mark>');
|
|
});
|
|
|
|
// Update match count after render
|
|
$effect(() => {
|
|
const html = highlightedLogs();
|
|
|
|
if (logSearchQuery && logsRef) {
|
|
setTimeout(() => {
|
|
const matches = logsRef.querySelectorAll('.search-match');
|
|
matchCount = matches.length;
|
|
currentMatchIndex = 0;
|
|
if (matches.length > 0) {
|
|
matches[0].classList.add('current-match');
|
|
matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 100);
|
|
} else {
|
|
matchCount = 0;
|
|
currentMatchIndex = 0;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden {className}">
|
|
<!-- Header bar -->
|
|
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
|
|
<div class="flex items-center gap-2">
|
|
{#if loading}
|
|
<RefreshCw class="w-3 h-3 text-zinc-500 animate-spin" />
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- Auto-refresh button -->
|
|
<button
|
|
onclick={() => onAutoRefreshChange?.(!autoRefresh)}
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoRefresh ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
|
|
title="Toggle auto-refresh"
|
|
>
|
|
<RefreshCw class="w-3 h-3" />
|
|
</button>
|
|
<!-- Auto-scroll button -->
|
|
<button
|
|
onclick={() => onAutoScrollChange?.(!autoScroll)}
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors {autoScroll ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800'}"
|
|
title="Toggle auto-scroll"
|
|
>
|
|
<ArrowDownToLine class="w-3 h-3" />
|
|
</button>
|
|
<!-- Font size -->
|
|
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => fontSize = Number(v)}>
|
|
<Select.Trigger class="h-6 w-16 bg-zinc-800 border-zinc-700 text-xs text-zinc-300 px-1.5">
|
|
<Type class="w-3 h-3 mr-1 text-zinc-400" />
|
|
<span>{fontSize}px</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each fontSizeOptions as size}
|
|
<Select.Item value={String(size)} label="{size}px">
|
|
<Type class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
|
{size}px
|
|
</Select.Item>
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
<!-- Word wrap -->
|
|
<button
|
|
onclick={() => wordWrap = !wordWrap}
|
|
class="p-1 rounded hover:bg-zinc-800 transition-colors {wordWrap ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
|
|
title="Toggle word wrap"
|
|
>
|
|
<WrapText class="w-3 h-3 transition-colors {wordWrap ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
|
|
</button>
|
|
<!-- Search -->
|
|
{#if logSearchActive}
|
|
<div class="flex items-center gap-1 bg-zinc-800 rounded px-1.5 py-0.5">
|
|
<Search class="w-3 h-3 text-amber-400" />
|
|
<input
|
|
bind:this={logSearchInputRef}
|
|
type="text"
|
|
placeholder="Search..."
|
|
bind:value={logSearchQuery}
|
|
onkeydown={handleLogSearchKeydown}
|
|
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
|
|
/>
|
|
{#if matchCount > 0}
|
|
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
|
|
{:else if logSearchQuery}
|
|
<span class="text-xs text-zinc-500">0/0</span>
|
|
{/if}
|
|
<button onclick={() => navigateMatch('prev')} class="p-0.5 rounded hover:bg-zinc-700" title="Previous">
|
|
<ChevronUp class="w-3 h-3 text-zinc-400" />
|
|
</button>
|
|
<button onclick={() => navigateMatch('next')} class="p-0.5 rounded hover:bg-zinc-700" title="Next">
|
|
<ChevronDown class="w-3 h-3 text-zinc-400" />
|
|
</button>
|
|
<button onclick={closeLogSearch} class="p-0.5 rounded hover:bg-zinc-700" title="Close">
|
|
<X class="w-3 h-3 text-zinc-400" />
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
onclick={toggleLogSearch}
|
|
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
title="Search logs"
|
|
>
|
|
<Search class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
</button>
|
|
{/if}
|
|
<!-- Copy -->
|
|
<button
|
|
onclick={copyLogs}
|
|
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
title="Copy logs"
|
|
>
|
|
<Copy class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
</button>
|
|
<!-- Download -->
|
|
<button
|
|
onclick={downloadLogs}
|
|
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
title="Download logs"
|
|
>
|
|
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
</button>
|
|
<!-- Refresh -->
|
|
<button
|
|
onclick={() => onRefresh?.()}
|
|
class="p-1 rounded hover:bg-zinc-800 transition-colors"
|
|
title="Refresh logs"
|
|
>
|
|
<RefreshCw class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs content -->
|
|
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
|
|
{#if logs}
|
|
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
|
|
{:else if loading}
|
|
<div class="flex items-center justify-center h-full text-muted-foreground">
|
|
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
|
|
Loading logs...
|
|
</div>
|
|
{:else}
|
|
<p class="text-zinc-500 text-sm">No logs available</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
:global(.search-match) {
|
|
background-color: rgba(234, 179, 8, 0.4);
|
|
color: #fef3c7;
|
|
border-radius: 2px;
|
|
padding: 1px 2px;
|
|
box-shadow: 0 0 4px rgba(234, 179, 8, 0.5);
|
|
}
|
|
:global(.search-match.current-match) {
|
|
background-color: rgba(234, 179, 8, 0.8);
|
|
color: #1a1a1a;
|
|
font-weight: 600;
|
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
|
|
outline: 2px solid rgb(250, 204, 21);
|
|
}
|
|
</style>
|