Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions desktop/src/renderer/src/lib/__mocks__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ if (
}
}

// jsdom doesn't implement ResizeObserver
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}

// jsdom doesn't implement matchMedia
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
Expand Down
125 changes: 97 additions & 28 deletions desktop/src/renderer/src/lib/components/log/LogTable.svelte
Original file line number Diff line number Diff line change
@@ -1,49 +1,118 @@
<script lang="ts">
import { badgeVariants } from "$lib/components/ui/badge/index.js"
import * as Table from "$lib/components/ui/table/index.js"
import { parseLogLine } from "$lib/utils/log-parser.js"

let { lines, class: className = "" }: { lines: string[]; class?: string } =
$props()
let {
lines,
class: className = "",
maxHeightClass = "max-h-96",
rowHeight = 28,
overscan = 12,
follow = false,
}: {
lines: string[]
class?: string
maxHeightClass?: string
rowHeight?: number
overscan?: number
follow?: boolean
} = $props()

let parsed = $derived(lines.map(parseLogLine))
let viewport = $state<HTMLDivElement | null>(null)
let scrollTop = $state(0)
let viewportHeight = $state(0)
let pinnedToBottom = $state(true)

let total = $derived(lines.length)
let totalHeight = $derived(total * rowHeight)

let startIndex = $derived(
Math.max(0, Math.floor(scrollTop / rowHeight) - overscan),
)
let endIndex = $derived(
Math.min(
total,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
),
)

let visible = $derived(
lines.slice(startIndex, endIndex).map((raw, i) => ({
index: startIndex + i,
line: parseLogLine(raw),
})),
)

function onScroll() {
if (!viewport) return
scrollTop = viewport.scrollTop
const distanceFromBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
pinnedToBottom = distanceFromBottom <= rowHeight
}

$effect(() => {
if (!viewport) return
const observer = new ResizeObserver(() => {
if (viewport) viewportHeight = viewport.clientHeight
})
observer.observe(viewport)
viewportHeight = viewport.clientHeight
return () => observer.disconnect()
})

$effect(() => {
// Follow the tail as new lines arrive, but only while the user is at the bottom.
void total
if (follow && pinnedToBottom && viewport) {
viewport.scrollTop = viewport.scrollHeight
}
})
</script>

<Table.Root class="w-full table-fixed {className}">
<Table.Header class="sticky top-0 z-10 bg-background">
<Table.Row>
<Table.Head class="w-20">Time</Table.Head>
<Table.Head class="w-24">Level</Table.Head>
<Table.Head class="max-w-0">Message</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each parsed as line, i (i)}
<Table.Row
<div
bind:this={viewport}
onscroll={onScroll}
class="relative overflow-auto rounded-md border {maxHeightClass} {className}"
>
<div
class="sticky top-0 z-10 grid grid-cols-[5rem_6rem_1fr] border-b bg-background text-start font-medium"
style="height: {rowHeight}px"
>
<div class="flex items-center px-2">Time</div>
<div class="flex items-center px-2">Level</div>
<div class="flex items-center px-2">Message</div>
</div>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div class="relative" style="height: {totalHeight}px">
{#each visible as row (row.index)}
<div
class={[
line.level === "fatal" || line.level === "error" ? "bg-destructive/5" : "",
line.level === "warn" ? "bg-amber-500/5" : "",
"absolute grid w-full grid-cols-[5rem_6rem_1fr] items-center border-b",
row.line.level === "fatal" || row.line.level === "error" ? "bg-destructive/5" : "",
row.line.level === "warn" ? "bg-amber-500/5" : "",
].filter(Boolean).join(" ")}
style="top: {row.index * rowHeight}px; height: {rowHeight}px"
>
<Table.Cell class="font-mono text-xs text-muted-foreground">{line.time}</Table.Cell>
<Table.Cell>
{#if line.level}
<div class="truncate px-2 font-mono text-xs text-muted-foreground">{row.line.time}</div>
<div class="px-2">
{#if row.line.level}
<span
class={badgeVariants({
variant:
line.level === "fatal" || line.level === "error"
row.line.level === "fatal" || row.line.level === "error"
? "destructive"
: line.level === "warn"
: row.line.level === "warn"
? "outline"
: "secondary",
})}
>
{line.level}
{row.line.level}
</span>
{/if}
</Table.Cell>
<Table.Cell class="text-sm max-w-0 whitespace-normal break-words" title={line.message}>{line.message}</Table.Cell>
</Table.Row>
</div>
<div class="truncate px-2 text-sm" title={row.line.message}>{row.line.message}</div>
</div>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
14 changes: 9 additions & 5 deletions desktop/src/renderer/src/lib/components/terminal/Terminal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ let containerEl: HTMLDivElement | undefined = $state()
let term: Terminal | undefined
let fitAddon: FitAddon | undefined
let resizeObserver: ResizeObserver | undefined
let lastCols = -1
let lastRows = -1

const darkTheme = {
background: "#1e1e2e",
Expand Down Expand Up @@ -187,11 +189,13 @@ onMount(async () => {
}

resizeObserver = new ResizeObserver(() => {
if (fitAddon && term) {
fitAddon.fit()
term.refresh(0, term.rows - 1)
terminalResize(sessionId, term.cols, term.rows)
}
if (!fitAddon || !term) return
fitAddon.fit()
if (term.cols === lastCols && term.rows === lastRows) return
lastCols = term.cols
lastRows = term.rows
term.refresh(0, term.rows - 1)
terminalResize(sessionId, term.cols, term.rows)
})
resizeObserver.observe(containerEl)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,15 @@ let ideSearch = $state("")
// Launch state
let commandId = $state<string | null>(null)
let outputLines = $state<string[]>([])
let outputEl = $state<HTMLDivElement | null>(null)
let launchRunning = $state(false)
let launchError = $state("")
let launchSuccess = $state(false)
let launchedWorkspaceId = $state<string | null>(null)
let confirmCancelOpen = $state(false)
let unlisten: UnlistenFn | null = null
let watchdog: ReturnType<typeof setTimeout> | null = null
let pendingLines: string[] = []
let flushHandle: number | null = null

// Image platform compatibility (image source only)
let hostPlatform = $state("")
Expand Down Expand Up @@ -331,6 +332,11 @@ function reset() {
ideSearch = ""
commandId = null
outputLines = []
pendingLines = []
if (flushHandle !== null) {
cancelAnimationFrame(flushHandle)
flushHandle = null
}
launchRunning = false
launchError = ""
launchSuccess = false
Expand Down Expand Up @@ -400,16 +406,31 @@ onMount(() => {
onDestroy(() => {
unlisten?.()
clearWatchdog()
if (flushHandle !== null) {
cancelAnimationFrame(flushHandle)
flushHandle = null
}
})

function flushLines() {
flushHandle = null
if (pendingLines.length === 0) return
outputLines = [...outputLines, ...pendingLines]
pendingLines = []
}

function handleProgress(progress: CommandProgress, wsId: string | undefined) {
if (progress.message) {
outputLines = [...outputLines, progress.message]
requestAnimationFrame(() => {
outputEl?.scrollIntoView?.({ block: "end", behavior: "smooth" })
})
pendingLines.push(progress.message)
if (flushHandle === null) {
flushHandle = requestAnimationFrame(flushLines)
}
}
if (progress.done) {
if (flushHandle !== null) {
cancelAnimationFrame(flushHandle)
}
flushLines()
launchRunning = false
clearWatchdog()
if (isCommandSuccess(progress.message)) {
Expand All @@ -430,6 +451,11 @@ async function handleLaunch() {
launchError = ""
launchSuccess = false
outputLines = []
pendingLines = []
if (flushHandle !== null) {
cancelAnimationFrame(flushHandle)
flushHandle = null
}
commandId = null
launchedWorkspaceId = null
clearWatchdog()
Expand Down Expand Up @@ -1121,10 +1147,7 @@ function selectTemplate(t: { name: string; source: string }) {
Copy
</Button>
</div>
<div class="max-h-80 overflow-auto rounded-md border">
<LogTable lines={outputLines} />
<div bind:this={outputEl}></div>
</div>
<LogTable lines={outputLines} maxHeightClass="max-h-80" follow />
</div>
{:else if launchRunning}
<div class="flex items-center justify-center py-8">
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/renderer/src/lib/utils/log-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,24 @@ function tryParseInnerLog(
*
* ANSI codes are stripped before parsing.
*/
const parseCache = new Map<string, ParsedLogLine>()
const PARSE_CACHE_MAX = 10_000

export function parseLogLine(raw: string): ParsedLogLine {
const cached = parseCache.get(raw)
if (cached) return cached

const result = parseLogLineUncached(raw)

if (parseCache.size >= PARSE_CACHE_MAX) {
const oldest = parseCache.keys().next().value
if (oldest !== undefined) parseCache.delete(oldest)
}
parseCache.set(raw, result)
return result
}

function parseLogLineUncached(raw: string): ParsedLogLine {
const clean = stripAnsi(raw)

// Zap console format (tab-separated): ISO8601\tLEVEL\tmessage\tsource.go:NNN
Expand Down
Loading
Loading