Add logs tab with real-time server output and consolidate syntax highlighting
- Implement dedicated Logs tab showing stdout/stderr from OpenCode server - Add log level parsing (INFO, ERROR, WARN, DEBUG) with color coding - Stream logs from main process to renderer via IPC events - Persist scroll position and auto-scroll state per instance - Synchronize instance IDs between renderer and main process - Consolidate syntax highlighting to single shared highlighter instance - Optimize markdown rendering with global highlighter initialization - Fix code block copy button to always appear on right side - Enable debug logging with --print-logs --log-level DEBUG flags
This commit is contained in:
@@ -1,67 +1,42 @@
|
||||
import { createSignal, onMount, Show } from "solid-js"
|
||||
import { getHighlighter, type Highlighter } from "shiki"
|
||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
|
||||
interface CodeBlockInlineProps {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
let highlighter: Highlighter | null = null
|
||||
|
||||
async function getOrCreateHighlighter() {
|
||||
if (!highlighter) {
|
||||
highlighter = await getHighlighter({
|
||||
themes: ["github-light", "github-dark"],
|
||||
langs: [
|
||||
"typescript",
|
||||
"javascript",
|
||||
"python",
|
||||
"bash",
|
||||
"json",
|
||||
"html",
|
||||
"css",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"sql",
|
||||
"rust",
|
||||
"go",
|
||||
"cpp",
|
||||
"c",
|
||||
"java",
|
||||
"csharp",
|
||||
"php",
|
||||
"ruby",
|
||||
"swift",
|
||||
"kotlin",
|
||||
"diff",
|
||||
"shell",
|
||||
],
|
||||
})
|
||||
}
|
||||
return highlighter
|
||||
}
|
||||
|
||||
export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [html, setHtml] = createSignal("")
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [ready, setReady] = createSignal(false)
|
||||
let highlighter: Highlighter | null = null
|
||||
|
||||
onMount(async () => {
|
||||
const hl = await getOrCreateHighlighter()
|
||||
highlighter = await getSharedHighlighter()
|
||||
setReady(true)
|
||||
updateHighlight(hl)
|
||||
updateHighlight()
|
||||
})
|
||||
|
||||
const updateHighlight = async (hl: Highlighter) => {
|
||||
createEffect(() => {
|
||||
if (ready()) {
|
||||
updateHighlight()
|
||||
}
|
||||
})
|
||||
|
||||
const updateHighlight = () => {
|
||||
if (!highlighter) return
|
||||
|
||||
if (!props.language) {
|
||||
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighted = hl.codeToHtml(props.code, {
|
||||
const highlighted = highlighter.codeToHtml(props.code, {
|
||||
lang: props.language,
|
||||
theme: isDark() ? "github-dark" : "github-light",
|
||||
})
|
||||
@@ -116,14 +91,3 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
|
||||
118
src/components/logs-view.tsx
Normal file
118
src/components/logs-view.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js"
|
||||
import { instances } from "../stores/instances"
|
||||
import { Trash2, ChevronDown } from "lucide-solid"
|
||||
import type { LogEntry } from "../types/instance"
|
||||
|
||||
interface LogsViewProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
|
||||
|
||||
const LogsView: Component<LogsViewProps> = (props) => {
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const savedState = logsScrollState.get(props.instanceId)
|
||||
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
|
||||
|
||||
const instance = () => instances().get(props.instanceId)
|
||||
const logs = () => instance()?.logs ?? []
|
||||
|
||||
onMount(() => {
|
||||
if (scrollRef && savedState) {
|
||||
scrollRef.scrollTop = savedState.scrollTop
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (scrollRef) {
|
||||
logsScrollState.set(props.instanceId, {
|
||||
scrollTop: scrollRef.scrollTop,
|
||||
autoScroll: autoScroll(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (autoScroll() && scrollRef && logs().length > 0) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
|
||||
|
||||
setAutoScroll(isAtBottom)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollRef) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
return "text-red-600 dark:text-red-400"
|
||||
case "warn":
|
||||
return "text-yellow-600 dark:text-yellow-400"
|
||||
case "debug":
|
||||
return "text-gray-500 dark:text-gray-500"
|
||||
default:
|
||||
return "text-gray-900 dark:text-gray-100"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full bg-white dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Server Logs</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-gray-900 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
<Show
|
||||
when={logs().length > 0}
|
||||
fallback={<div class="text-gray-500 dark:text-gray-500 text-center py-8">Waiting for server output...</div>}
|
||||
>
|
||||
<For each={logs()}>
|
||||
{(entry) => (
|
||||
<div class="flex gap-3 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 px-2 -mx-2 rounded">
|
||||
<span class="text-gray-500 dark:text-gray-500 select-none shrink-0">{formatTime(entry.timestamp)}</span>
|
||||
<span class={`${getLevelColor(entry.level)} break-all`}>{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={!autoScroll()}>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
class="absolute bottom-6 right-6 px-3 py-2 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsView
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createSignal, onMount, Show } from "solid-js"
|
||||
import { initMarkdown, renderMarkdown } from "../lib/markdown"
|
||||
import { createEffect, createSignal, Show } from "solid-js"
|
||||
import { renderMarkdown } from "../lib/markdown"
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
@@ -8,29 +8,11 @@ interface MarkdownProps {
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const [html, setHtml] = createSignal("")
|
||||
const [ready, setReady] = createSignal(false)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
onMount(async () => {
|
||||
await initMarkdown(props.isDark ?? false)
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
if (ready()) {
|
||||
const rendered = await renderMarkdown(props.content)
|
||||
setHtml(rendered)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
if (props.isDark !== undefined) {
|
||||
await initMarkdown(props.isDark)
|
||||
if (ready()) {
|
||||
const rendered = await renderMarkdown(props.content)
|
||||
setHtml(rendered)
|
||||
}
|
||||
}
|
||||
const rendered = await renderMarkdown(props.content)
|
||||
setHtml(rendered)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -87,9 +69,5 @@ export function Markdown(props: MarkdownProps) {
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={ready()} fallback={<div class="text-gray-500">Loading...</div>}>
|
||||
<div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
|
||||
</Show>
|
||||
)
|
||||
return <div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user