Files
oldboy/src/lib/components/editor/LogPanel.svelte
2025-10-15 16:52:39 +02:00

344 lines
8.2 KiB
Svelte

<script lang="ts">
import { Copy, Trash2, Search, Pause, Play } from 'lucide-svelte';
import { getAppContext } from '../../contexts/app-context';
import type { LogEntry } from '../../csound';
import { tick } from 'svelte';
interface Props {
logs?: LogEntry[];
onHeaderClick?: () => void;
collapsed?: boolean;
}
let { logs = [], onHeaderClick, collapsed = false }: Props = $props();
const { csound } = getAppContext();
let searchQuery = $state('');
let autoFollow = $state(true);
let searchVisible = $state(false);
let logContentEl: HTMLDivElement;
let previousLogsLength = 0;
function fuzzyMatch(text: string, query: string): boolean {
const textLower = text.toLowerCase();
const queryLower = query.toLowerCase();
let queryIndex = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
return queryIndex === queryLower.length;
}
const filteredLogs = $derived(
searchQuery.trim() === ''
? logs
: logs.filter(log => fuzzyMatch(log.message, searchQuery))
);
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
}
async function copyLogs() {
if (logs.length === 0) return;
const logText = logs
.map(log => `[${formatTime(log.timestamp)}] ${log.type.toUpperCase()}: ${log.message}`)
.join('\n');
try {
await navigator.clipboard.writeText(logText);
} catch (err) {
console.error('Failed to copy logs:', err);
}
}
function clearLogs() {
csound.clearLogs();
}
function toggleAutoFollow() {
autoFollow = !autoFollow;
if (autoFollow) {
scrollToBottom();
}
}
function toggleSearch() {
searchVisible = !searchVisible;
if (!searchVisible) {
searchQuery = '';
}
}
async function scrollToBottom() {
if (logContentEl) {
await tick();
logContentEl.scrollTop = logContentEl.scrollHeight;
}
}
function handleScroll() {
if (!logContentEl) return;
const isAtBottom =
Math.abs(logContentEl.scrollHeight - logContentEl.scrollTop - logContentEl.clientHeight) < 5;
if (isAtBottom && !autoFollow) {
autoFollow = true;
} else if (!isAtBottom && autoFollow) {
autoFollow = false;
}
}
$effect(() => {
if (autoFollow && logs.length > previousLogsLength && logContentEl) {
scrollToBottom();
}
previousLogsLength = logs.length;
});
</script>
<div class="log-panel">
<div class="log-header" onclick={onHeaderClick}>
<span class="log-title">Logs</span>
{#if !collapsed}
<div class="log-actions" onclick={(e) => e.stopPropagation()}>
<button
class="action-button"
class:search-active={searchVisible}
onclick={toggleSearch}
title={searchVisible ? 'Hide search' : 'Show search'}
>
<Search size={14} />
</button>
<button
class="action-button"
class:pause-active={!autoFollow}
onclick={toggleAutoFollow}
title={autoFollow ? 'Pause auto-follow' : 'Resume auto-follow'}
>
{#if autoFollow}
<Pause size={14} />
{:else}
<Play size={14} />
{/if}
</button>
<button
class="action-button"
onclick={copyLogs}
disabled={logs.length === 0}
title="Copy logs"
>
<Copy size={14} />
</button>
<button
class="action-button"
onclick={clearLogs}
disabled={logs.length === 0}
title="Clear logs"
>
<Trash2 size={14} />
</button>
</div>
{/if}
</div>
{#if searchVisible && !collapsed}
<div class="search-bar">
<Search size={14} class="search-icon" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search logs (fuzzy)..."
class="search-input"
/>
</div>
{/if}
{#if !collapsed}
<div class="log-content" bind:this={logContentEl} onscroll={handleScroll}>
{#if filteredLogs.length === 0 && searchQuery.trim() !== ''}
<div class="empty-state">No matching logs found...</div>
{:else if logs.length === 0}
<div class="empty-state">No output yet...</div>
{:else}
{#each filteredLogs as log, i}
<div class="log-entry" class:error={log.type === 'error'}>
<span class="log-timestamp">{formatTime(log.timestamp)}</span>
<span class="log-message">{log.message}</span>
</div>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.log-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-base);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-3);
border-bottom: 1px solid var(--color-border-default);
cursor: pointer;
transition: background-color var(--transition-base);
}
.log-header:hover {
background-color: var(--color-hover-overlay);
}
.search-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface-2);
border-bottom: 1px solid var(--color-border-default);
}
.search-bar :global(.search-icon) {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.search-input {
flex: 1;
background-color: var(--color-bg-base);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default);
padding: 0.375rem var(--space-sm);
font-size: var(--font-base);
font-family: var(--font-mono);
outline: none;
transition: border-color var(--transition-base);
}
.search-input:focus {
border-color: var(--color-text-tertiary);
}
.search-input::placeholder {
color: var(--color-text-disabled);
}
.log-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.log-actions {
display: flex;
gap: var(--space-xs);
}
.action-button {
padding: var(--space-xs);
background-color: transparent;
color: var(--color-text-secondary);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-base);
}
.action-button:hover:not(:disabled) {
color: var(--color-text-primary);
background-color: var(--color-active-overlay);
}
.action-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.action-button.pause-active {
color: rgba(255, 200, 100, 0.9);
background-color: rgba(255, 200, 100, 0.15);
}
.action-button.pause-active:hover {
color: rgba(255, 200, 100, 1);
background-color: rgba(255, 200, 100, 0.25);
}
.action-button.search-active {
color: rgba(100, 200, 255, 0.9);
background-color: rgba(100, 200, 255, 0.15);
}
.action-button.search-active:hover {
color: rgba(100, 200, 255, 1);
background-color: rgba(100, 200, 255, 0.25);
}
.log-content {
flex: 1;
overflow-y: auto;
padding: var(--space-sm);
font-family: var(--font-mono);
font-size: var(--font-base);
}
.empty-state {
color: var(--color-text-tertiary);
text-align: center;
padding: var(--space-2xl);
}
.log-entry {
display: flex;
gap: var(--space-md);
padding: var(--space-xs) var(--space-sm);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-surface-3);
}
.log-entry:hover {
background-color: var(--color-surface-2);
}
.log-entry.error {
background-color: rgba(255, 0, 0, 0.1);
border-left: 3px solid rgba(255, 0, 0, 0.6);
}
.log-entry.error .log-message {
color: rgba(255, 100, 100, 0.95);
}
.log-timestamp {
color: var(--color-text-tertiary);
min-width: 6rem;
font-size: var(--font-sm);
}
.log-message {
flex: 1;
word-break: break-word;
}
</style>