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
126 changes: 101 additions & 25 deletions cockpit/render/shared/streaming-timeline.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,124 @@ import { StreamingSimulator } from './streaming-simulator';
@Component({
selector: 'streaming-timeline',
standalone: true,
styles: `
:host {
--tl-green: var(--ds-render-green, #1a7a40);
--tl-green-bright: #35b06a;
display: block;
}
.tl {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--ds-surface, #1c1c1c);
border-top: 1px solid var(--ds-border, #2d2d2d);
}
.tl__play {
width: 34px;
height: 34px;
border-radius: 999px;
border: 0;
flex-shrink: 0;
color: #eafff2;
background: var(--tl-green);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 12px rgba(26, 122, 64, 0.5);
transition: transform 0.1s ease, box-shadow 0.15s ease;
}
.tl__play:hover { box-shadow: 0 3px 16px rgba(53, 176, 106, 0.6); }
.tl__play:active { transform: scale(0.94); }
.tl__track {
flex: 1;
position: relative;
height: 6px;
border-radius: 999px;
background: var(--ds-surface-tinted, #2c2c2c);
cursor: pointer;
}
.tl__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--tl-green), var(--tl-green-bright));
}
.tl__handle {
position: absolute;
top: 50%;
width: 15px;
height: 15px;
border-radius: 999px;
background: #fff;
border: 2px solid var(--tl-green-bright);
transform: translate(-50%, -50%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
transition: left 0.075s linear;
}
.tl__count {
flex-shrink: 0;
min-width: 100px;
text-align: right;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: 11px;
color: var(--ds-text-muted, #a0a0a0);
font-variant-numeric: tabular-nums;
}
.tl__count b { color: var(--ds-text-primary, #f5f5f5); font-weight: 600; }
.tl__speeds { display: flex; gap: 4px; flex-shrink: 0; }
.tl__speed {
font-size: 10.5px;
padding: 5px 10px;
border-radius: 7px;
border: 1px solid var(--ds-border, #2d2d2d);
background: var(--ds-surface-dim, #0a0a0a);
color: var(--ds-text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
.tl__speed:hover { color: var(--ds-text-secondary, #c8c8c8); }
.tl__speed--on {
color: var(--tl-green-bright);
border-color: rgba(53, 176, 106, 0.35);
background: rgba(53, 176, 106, 0.12);
font-weight: 600;
}
`,
template: `
<div class="flex items-center gap-3 bg-[var(--tplane-chat-surface)] rounded-lg px-4 py-3">
<button
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0 bg-[var(--tplane-chat-primary)] hover:bg-[var(--tplane-chat-primary)] transition-colors"
(click)="simulator().toggle()">
<div class="tl">
<button class="tl__play" type="button" aria-label="Play or pause" (click)="simulator().toggle()">
@if (simulator().playing()) {
<svg width="14" height="14" viewBox="0 0 14 14" fill="white">
<rect x="3" y="2" width="3" height="10" rx="1"/>
<rect x="8" y="2" width="3" height="10" rx="1"/>
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<rect x="3" y="2" width="3" height="10" rx="1" />
<rect x="8" y="2" width="3" height="10" rx="1" />
</svg>
} @else {
<svg width="14" height="14" viewBox="0 0 14 14" fill="white">
<polygon points="4,2 12,7 4,12"/>
<svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<polygon points="4,2 12,7 4,12" />
</svg>
}
</button>

<div
#track
class="flex-1 relative h-1.5 bg-[var(--tplane-chat-surface-alt)] rounded-full cursor-pointer"
class="tl__track"
(mousedown)="onTrackMouseDown($event)"
(touchstart)="onTrackTouchStart($event)">
<div
class="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-indigo-500 to-indigo-400"
[style.width.%]="simulator().progress() * 100">
</div>
<div
class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full border-2 border-[var(--tplane-chat-primary)] shadow-lg shadow-indigo-500/30 -translate-x-1/2 transition-[left] duration-75"
[style.left.%]="simulator().progress() * 100">
</div>
<div class="tl__fill" [style.width.%]="simulator().progress() * 100"></div>
<div class="tl__handle" [style.left.%]="simulator().progress() * 100"></div>
</div>

<div class="text-xs text-[var(--tplane-chat-text-muted)] tabular-nums shrink-0 min-w-[100px] text-right">
<span class="text-[var(--tplane-chat-text)] font-semibold">{{ simulator().position() }}</span>
/ {{ simulator().total() }} chars
</div>
<div class="tl__count"><b>{{ simulator().position() }}</b> / {{ simulator().total() }} chars</div>

<div class="flex gap-1 shrink-0">
<div class="tl__speeds">
@for (s of speeds; track s) {
<button
class="text-[10px] px-2.5 py-1 rounded transition-colors"
[class]="simulator().speed() === s ? 'text-[var(--tplane-chat-primary)] bg-[var(--tplane-chat-surface-alt)] font-semibold' : 'text-[var(--tplane-chat-text-muted)] bg-[var(--tplane-chat-surface-alt)] hover:text-[var(--tplane-chat-text-muted)]'"
type="button"
class="tl__speed"
[class.tl__speed--on]="simulator().speed() === s"
(click)="simulator().setSpeed(s)">
{{ s }}x
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT
import { highlightJson } from './json-highlight';

describe('highlightJson', () => {
it('returns no tokens for empty input', () => {
expect(highlightJson('')).toEqual([]);
});

it('classifies an object key vs a string value', () => {
const toks = highlightJson('{"type": "Card"}');
expect(toks.find((t) => t.text === '"type"')?.kind).toBe('key');
expect(toks.find((t) => t.text === '"Card"')?.kind).toBe('string');
});

it('marks structural characters as punct', () => {
const puncts = highlightJson('{}[],:').filter((t) => t.kind === 'punct');
expect(puncts.map((t) => t.text)).toEqual(['{', '}', '[', ']', ',', ':']);
});

it('tokenizes numbers including negatives and exponents', () => {
const nums = highlightJson('[1, -2.5, 3e4]').filter((t) => t.kind === 'number');
expect(nums.map((t) => t.text)).toEqual(['1', '-2.5', '3e4']);
});

it('tokenizes true/false/null as literals', () => {
const lits = highlightJson('[true, false, null]').filter((t) => t.kind === 'literal');
expect(lits.map((t) => t.text)).toEqual(['true', 'false', 'null']);
});

it('treats an unterminated trailing string as a plain string, not a key', () => {
const toks = highlightJson('{"title": "Streaming De');
expect(toks.find((t) => t.text === '"title"')?.kind).toBe('key');
const last = toks[toks.length - 1];
expect(last.text).toBe('"Streaming De');
expect(last.kind).toBe('string');
});

it('is loss-less: joining token texts reproduces the input', () => {
const sample = '{\n "root": "root",\n "elements": {\n "a": { "type": "Card" }\n }\n}';
expect(highlightJson(sample).map((t) => t.text).join('')).toBe(sample);
});

it('preserves whitespace as plain tokens', () => {
const toks = highlightJson('{ }');
expect(toks.map((t) => t.text).join('')).toBe('{ }');
expect(toks.some((t) => t.kind === 'plain' && t.text === ' ')).toBe(true);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing edge-case test: string truncated after a backslash

The j += 2 escape branch at json-highlight.ts:51 can advance j past n when the input ends with \. The main guards (Math.min(j, n)) handle it correctly, but it would be good to have a test confirming the tokenizer doesn't throw and remains lossless:

it('handles a string truncated after an escape backslash', () => {
  const input = '"hello\\';
  const toks = highlightJson(input);
  expect(toks.map((t) => t.text).join('')).toBe(input);
  expect(toks[0].kind).toBe('string'); // unterminated → string, not key
});

85 changes: 85 additions & 0 deletions cockpit/render/spec-rendering/angular/src/app/json-highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT

export type JsonTokenKind = 'key' | 'string' | 'punct' | 'number' | 'literal' | 'plain';

export interface JsonToken {
text: string;
kind: JsonTokenKind;
}

const PUNCT = new Set(['{', '}', '[', ']', ':', ',']);
const WS = new Set([' ', '\n', '\r', '\t']);
const isWs = (c: string) => WS.has(c);
const isDigit = (c: string) => c >= '0' && c <= '9';
const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
const isNumberPart = (c: string) => isDigit(c) || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-';

/**
* Tokenize a (possibly incomplete) streaming JSON string for syntax
* highlighting. Never throws; tolerant of truncation. A string token is
* classified as a `key` only when it is properly closed AND the next
* non-whitespace character is a colon; the trailing (possibly unterminated)
* string has no colon yet and is emitted as a plain string. The token stream
* is loss-less — concatenating every token's `text` reproduces the input.
*/
export function highlightJson(raw: string): JsonToken[] {
const tokens: JsonToken[] = [];
const n = raw.length;
let i = 0;

while (i < n) {
const ch = raw[i];

if (isWs(ch)) {
let j = i + 1;
while (j < n && isWs(raw[j])) j++;
tokens.push({ text: raw.slice(i, j), kind: 'plain' });
i = j;
continue;
}

if (PUNCT.has(ch)) {
tokens.push({ text: ch, kind: 'punct' });
i += 1;
continue;
}

if (ch === '"') {
let j = i + 1;
let closed = false;
while (j < n) {
if (raw[j] === '\\') { j += 2; continue; }
if (raw[j] === '"') { j += 1; closed = true; break; }
j += 1;
}
const text = raw.slice(i, Math.min(j, n));
let k = j;
while (k < n && isWs(raw[k])) k++;
const isKey = closed && k < n && raw[k] === ':';
tokens.push({ text, kind: isKey ? 'key' : 'string' });
i = Math.min(j, n);
continue;
}

if (ch === '-' || isDigit(ch)) {
let j = i + 1;
while (j < n && isNumberPart(raw[j])) j++;
tokens.push({ text: raw.slice(i, j), kind: 'number' });
i = j;
continue;
}

if (isAlpha(ch)) {
let j = i + 1;
while (j < n && isAlpha(raw[j])) j++;
tokens.push({ text: raw.slice(i, j), kind: 'literal' });
i = j;
continue;
}

tokens.push({ text: ch, kind: 'plain' });
i += 1;
}

return tokens;
}
Loading
Loading