Skip to content

Commit 559167c

Browse files
committed
Add ConsoleOutput component for unified cell output display
1 parent 1794015 commit 559167c

2 files changed

Lines changed: 204 additions & 66 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<script lang="ts">
2+
/**
3+
* ConsoleOutput - Reusable console output display component
4+
* Based on PathView's ConsolePanel, adapted for per-cell output in documentation
5+
*/
6+
import Icon from './Icon.svelte';
7+
8+
export interface LogEntry {
9+
id: number;
10+
level: 'info' | 'warning' | 'error' | 'output';
11+
message: string;
12+
}
13+
14+
interface Props {
15+
/** Log entries to display */
16+
logs: LogEntry[];
17+
/** Maximum height before scrolling (default: 200px) */
18+
maxHeight?: number;
19+
/** Placeholder text when empty (optional) */
20+
placeholder?: string;
21+
}
22+
23+
let { logs, maxHeight = 200, placeholder }: Props = $props();
24+
25+
let scrollContainer: HTMLDivElement | undefined = $state();
26+
let autoScroll = $state(true);
27+
let lastLogId = -1;
28+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
29+
30+
// Auto-scroll when new logs arrive
31+
$effect(() => {
32+
const latestId = logs.length > 0 ? logs[logs.length - 1].id : -1;
33+
if (latestId > lastLogId && autoScroll && scrollContainer) {
34+
// Clear any pending scroll
35+
if (scrollTimeout !== null) {
36+
clearTimeout(scrollTimeout);
37+
}
38+
// Use setTimeout to ensure DOM has updated
39+
scrollTimeout = setTimeout(() => {
40+
scrollTimeout = null;
41+
if (scrollContainer) {
42+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
43+
}
44+
}, 0);
45+
}
46+
lastLogId = latestId;
47+
});
48+
49+
// Cleanup timeout on unmount
50+
$effect(() => {
51+
return () => {
52+
if (scrollTimeout !== null) {
53+
clearTimeout(scrollTimeout);
54+
}
55+
};
56+
});
57+
58+
function handleScroll() {
59+
if (!scrollContainer) return;
60+
// Disable auto-scroll if user scrolls up
61+
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
62+
autoScroll = scrollTop + clientHeight >= scrollHeight - 20;
63+
}
64+
65+
function scrollToBottom() {
66+
if (scrollContainer) {
67+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
68+
autoScroll = true;
69+
}
70+
}
71+
</script>
72+
73+
<div class="console-output" style:max-height="{maxHeight}px">
74+
<div class="console-content" bind:this={scrollContainer} onscroll={handleScroll}>
75+
{#if logs.length === 0 && placeholder}
76+
<div class="placeholder">
77+
<p>{placeholder}</p>
78+
</div>
79+
{:else}
80+
{#each logs as log (log.id)}
81+
<div class="log-entry {log.level}">
82+
<span class="log-message">{log.message}</span>
83+
</div>
84+
{/each}
85+
{/if}
86+
</div>
87+
{#if !autoScroll && logs.length > 0}
88+
<button class="scroll-to-bottom" onclick={scrollToBottom} aria-label="Scroll to bottom">
89+
<Icon name="chevron-down" size={14} />
90+
</button>
91+
{/if}
92+
</div>
93+
94+
<style>
95+
.console-output {
96+
display: flex;
97+
flex-direction: column;
98+
overflow: hidden;
99+
position: relative;
100+
background: var(--surface);
101+
}
102+
103+
.console-content {
104+
flex: 1;
105+
overflow-y: auto;
106+
}
107+
108+
.placeholder {
109+
display: flex;
110+
align-items: center;
111+
justify-content: center;
112+
padding: var(--space-lg);
113+
color: var(--text-disabled);
114+
font-size: var(--font-base);
115+
text-align: center;
116+
}
117+
118+
.log-entry,
119+
.log-message {
120+
font-family: var(--font-mono);
121+
font-size: var(--font-base);
122+
font-weight: 500;
123+
line-height: 1.5;
124+
}
125+
126+
.log-entry {
127+
padding: var(--space-xs) var(--space-md);
128+
}
129+
130+
.log-entry.output {
131+
color: var(--text);
132+
}
133+
134+
.log-entry.info {
135+
color: var(--accent);
136+
}
137+
138+
.log-entry.warning {
139+
color: var(--warning);
140+
background: var(--warning-bg);
141+
}
142+
143+
.log-entry.error {
144+
color: var(--error);
145+
background: var(--error-bg);
146+
}
147+
148+
.log-message {
149+
white-space: pre-wrap;
150+
word-break: break-word;
151+
}
152+
153+
.scroll-to-bottom {
154+
position: absolute;
155+
bottom: var(--space-sm);
156+
left: 50%;
157+
transform: translateX(-50%);
158+
width: 24px;
159+
height: 24px;
160+
padding: 0;
161+
display: flex;
162+
align-items: center;
163+
justify-content: center;
164+
background: var(--surface-raised);
165+
border: 1px solid var(--border);
166+
border-radius: 50%;
167+
color: var(--text-muted);
168+
cursor: pointer;
169+
transition: all var(--transition-fast);
170+
box-shadow: var(--shadow-sm);
171+
}
172+
173+
.scroll-to-bottom:hover {
174+
background: var(--surface-hover);
175+
color: var(--text);
176+
}
177+
</style>

src/lib/components/common/NotebookCell.svelte

Lines changed: 27 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import CodeBlock from './CodeBlock.svelte';
88
import Icon from './Icon.svelte';
99
import { tooltip } from './Tooltip.svelte';
10+
import ConsoleOutput, { type LogEntry } from './ConsoleOutput.svelte';
1011
import { notebookStore, type CellStatus } from '$lib/stores/notebookStore';
1112
import { pyodideState } from '$lib/stores/pyodideStore';
1213
import CellOutput from '$lib/components/notebook/CellOutput.svelte';
@@ -40,8 +41,8 @@
4041
let codeBlockRef = $state<{ getCurrentCode: () => string } | undefined>(undefined);
4142
4243
// Execution output state
43-
let stdout = $state('');
44-
let stderr = $state('');
44+
let consoleLogs = $state<LogEntry[]>([]);
45+
let nextLogId = 0;
4546
let plots = $state<string[]>([]);
4647
let error = $state<{ message: string; traceback?: string } | null>(null);
4748
let duration = $state<number | null>(null);
@@ -55,19 +56,27 @@
5556
// Computed states
5657
let isRunning = $derived(cellState.status === 'running');
5758
let isPending = $derived(cellState.status === 'pending');
58-
let hasLiveOutput = $derived(stdout || stderr || plots.length > 0 || error);
59+
let hasLiveOutput = $derived(consoleLogs.length > 0 || plots.length > 0 || error);
5960
let showStaticOutputs = $derived(!hasLiveOutput && staticOutputs.length > 0);
6061
let hasOutput = $derived(hasLiveOutput || showStaticOutputs);
6162
63+
function addLog(message: string, level: LogEntry['level']) {
64+
consoleLogs = [...consoleLogs, { id: nextLogId++, level, message }];
65+
}
66+
67+
function clearLogs() {
68+
consoleLogs = [];
69+
nextLogId = 0;
70+
}
71+
6272
/**
6373
* Execute this cell's code (called by store during prerequisite chain)
6474
*/
6575
async function executeCell(): Promise<void> {
6676
const codeToRun = codeBlockRef?.getCurrentCode() ?? code;
6777
6878
// Clear previous output
69-
stdout = '';
70-
stderr = '';
79+
clearLogs();
7180
plots = [];
7281
error = null;
7382
duration = null;
@@ -84,19 +93,23 @@
8493
// Execute with streaming callbacks for real-time output
8594
const result = await execute(codeToRun, {
8695
onStdout: (text) => {
87-
stdout = stdout ? stdout + '\n' + text : text;
96+
addLog(text, 'output');
8897
},
8998
onStderr: (text) => {
90-
stderr = stderr ? stderr + '\n' + text : text;
99+
addLog(text, 'warning');
91100
},
92101
onPlot: (data) => {
93102
plots = [...plots, data];
94103
}
95104
});
96105
97-
// Final result (in case any output was missed)
98-
stdout = result.stdout;
99-
stderr = result.stderr;
106+
// Final result - add any output that wasn't streamed
107+
if (result.stdout && consoleLogs.every((log) => log.message !== result.stdout)) {
108+
addLog(result.stdout, 'output');
109+
}
110+
if (result.stderr && consoleLogs.every((log) => log.message !== result.stderr)) {
111+
addLog(result.stderr, 'warning');
112+
}
100113
plots = result.plots;
101114
duration = result.duration;
102115
@@ -133,8 +146,7 @@
133146
}
134147
135148
function clearOutput() {
136-
stdout = '';
137-
stderr = '';
149+
clearLogs();
138150
plots = [];
139151
error = null;
140152
duration = null;
@@ -210,38 +222,20 @@
210222
</div>
211223
{/if}
212224

213-
{#if stdout}
225+
{#if consoleLogs.length > 0}
214226
<div class="output-panel">
215227
<div class="panel-header">
216228
<span>Output</span>
217229
<div class="header-actions">
218230
{#if duration !== null}
219231
<span class="duration">{duration}ms</span>
220232
{/if}
221-
<button class="icon-btn" onclick={clearOutput} use:tooltip={'Clear'}>
222-
<Icon name="x" size={14} />
223-
</button>
224-
</div>
225-
</div>
226-
<div class="panel-body">
227-
<div class="output-text">{stdout}</div>
228-
</div>
229-
</div>
230-
{/if}
231-
232-
{#if stderr && !error}
233-
<div class="output-panel warning">
234-
<div class="panel-header">
235-
<span>Stderr</span>
236-
<div class="header-actions">
237-
<button class="icon-btn" onclick={clearOutput} use:tooltip={'Clear'}>
233+
<button class="icon-btn" onclick={clearLogs} use:tooltip={'Clear'}>
238234
<Icon name="x" size={14} />
239235
</button>
240236
</div>
241237
</div>
242-
<div class="panel-body">
243-
<div class="output-text stderr">{stderr}</div>
244-
</div>
238+
<ConsoleOutput logs={consoleLogs} maxHeight={200} />
245239
</div>
246240
{/if}
247241

@@ -331,8 +325,6 @@
331325
padding: 0;
332326
}
333327
334-
/* output-text styles are in app.css global rules */
335-
336328
/* Duration in header */
337329
.output-panel .duration {
338330
font-family: var(--font-ui);
@@ -363,20 +355,6 @@
363355
border-top: 1px solid var(--border);
364356
}
365357
366-
/* Warning panel (stderr) */
367-
.output-panel.warning {
368-
background: var(--warning-bg);
369-
}
370-
371-
.output-panel.warning .panel-header {
372-
background: transparent;
373-
color: var(--warning);
374-
}
375-
376-
.output-panel.warning .output-text {
377-
color: var(--warning);
378-
}
379-
380358
/* Plots */
381359
.plots-body {
382360
display: flex;
@@ -391,21 +369,4 @@
391369
border-radius: var(--radius-sm);
392370
background: transparent;
393371
}
394-
395-
/* Output text styling */
396-
.output-text {
397-
font-family: var(--font-mono);
398-
font-size: var(--font-base);
399-
font-weight: 400;
400-
line-height: 1.5;
401-
margin: 0;
402-
padding: var(--space-md);
403-
color: var(--text-muted);
404-
white-space: pre-wrap;
405-
word-break: break-word;
406-
}
407-
408-
.output-text.stderr {
409-
color: var(--warning);
410-
}
411372
</style>

0 commit comments

Comments
 (0)