Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
131 changes: 103 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,124 @@
<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"
import { cn } from "$lib/utils.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}
role="table"
aria-rowcount={total}
class={cn("relative overflow-auto rounded-md border", maxHeightClass, className)}
>
<div
role="row"
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 role="columnheader" class="flex items-center px-2">Time</div>
<div role="columnheader" class="flex items-center px-2">Level</div>
<div role="columnheader" class="flex items-center px-2">Message</div>
</div>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div role="rowgroup" class="relative" style="height: {totalHeight}px">
{#each visible as row (row.index)}
<div
role="row"
aria-rowindex={row.index + 1}
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 role="cell" class="truncate px-2 font-mono text-xs text-muted-foreground">{row.line.time}</div>
<div role="cell" 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 role="cell" class="truncate px-2 text-sm" title={row.line.message}>{row.line.message}</div>
</div>
{/each}
</Table.Body>
</Table.Root>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -529,14 +529,10 @@ function handleDone() {
>
Show logs
</summary>
<div class="max-h-48 overflow-y-auto border-t">
<LogTable lines={initLines} />
</div>
<LogTable lines={initLines} maxHeightClass="max-h-48" class="border-x-0 border-b-0 rounded-none" />
</details>
{:else}
<div class="max-h-48 overflow-y-auto rounded-md border bg-muted/30">
<LogTable lines={initLines} />
</div>
<LogTable lines={initLines} maxHeightClass="max-h-48" follow />
{/if}
{:else if initRunning}
<div class="flex items-center justify-center py-8">
Expand Down
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.push(...pendingLines)
pendingLines.length = 0
}

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