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
8 changes: 6 additions & 2 deletions apps/staged/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -150,15 +150,19 @@ lint:
test:
cd src-tauri && cargo test

# Run frontend unit tests
test-frontend:
pnpm test

# Type-check frontend (Svelte + TypeScript)
typecheck:
pnpm run check

# Format, then verify everything passes — run before pushing
check-all: fmt lint typecheck test
check-all: fmt lint typecheck test test-frontend

# Verify everything without modifying files — for CI
ci: fmt-check lint typecheck test
ci: fmt-check lint typecheck test test-frontend

# ============================================================================
# Maintenance
Expand Down
7 changes: 5 additions & 2 deletions apps/staged/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"tauri:build": "tauri build",
"tauri:release:config": "node scripts/build-tauri-release-config.mjs",
"release:updater:publish": "node scripts/publish-updater-to-github-release.mjs",
"release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs"
"release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
Expand All @@ -25,7 +27,8 @@
"svelte": "^5.46.4",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "^4.0.18"
},
"dependencies": {
"@builderbot/diff-viewer": "workspace:*",
Expand Down
61 changes: 1 addition & 60 deletions apps/staged/src/lib/features/actions/ActionOutputModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
listenToActionOutput,
listenToActionStatus,
} from './actions';
import { processChunksToLines, type TerminalLine } from './processOutput';

interface Props {
executionId: string;
Expand All @@ -67,12 +68,6 @@
// State
// =========================================================================

/** A processed terminal line ready for display. */
interface TerminalLine {
text: string;
stream: 'stdout' | 'stderr';
}

let status = $state<ActionStatus>('running');
let exitCode = $state<number | null>(null);
let outputChunks = $state<OutputChunk[]>([]);
Expand Down Expand Up @@ -151,60 +146,6 @@
}
}

/**
* Process raw output chunks into terminal lines, handling carriage returns.
*
* Terminal programs use \r (carriage return without newline) to overwrite the
* current line in-place — e.g. for progress bars. This function simulates
* that behavior:
* - \n finalizes the current line and starts a new one
* - \r (not followed by \n) resets the cursor to the start of the current
* line so subsequent text overwrites it
*/
function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] {
const lines: TerminalLine[] = [];
let currentText = '';
let currentStream: 'stdout' | 'stderr' = 'stdout';

for (const chunk of chunks) {
const raw = chunk.chunk;
const stream = chunk.stream;

for (let i = 0; i < raw.length; i++) {
const ch = raw[i];

if (ch === '\n') {
// Newline: finalize the current line and start a new one
lines.push({ text: currentText, stream: currentStream });
currentText = '';
currentStream = stream;
} else if (ch === '\r') {
// Carriage return: check if it's \r\n (treat as plain newline)
if (i + 1 < raw.length && raw[i + 1] === '\n') {
lines.push({ text: currentText, stream: currentStream });
currentText = '';
currentStream = stream;
i++; // skip the \n
} else {
// Bare \r: reset cursor to start of current line (overwrite)
currentText = '';
currentStream = stream;
}
} else {
currentText += ch;
currentStream = stream;
}
}
}

// Don't forget the last in-progress line
if (currentText.length > 0) {
lines.push({ text: currentText, stream: currentStream });
}

return lines;
}

/** Derived display lines — recomputed whenever outputChunks changes. */
let displayLines = $derived(processChunksToLines(outputChunks));

Expand Down
117 changes: 117 additions & 0 deletions apps/staged/src/lib/features/actions/processOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { processChunksToLines } from './processOutput';
import type { OutputChunk } from './actions';

/** Helper to build an OutputChunk. */
function chunk(text: string, stream: 'stdout' | 'stderr' = 'stdout'): OutputChunk {
return { chunk: text, stream, timestamp: 0 };
}

/** Helper to extract just the text from result lines. */
function texts(chunks: OutputChunk[]): string[] {
return processChunksToLines(chunks).map((l) => l.text);
}

describe('processChunksToLines', () => {
// ---------------------------------------------------------------------------
// Basic newline handling
// ---------------------------------------------------------------------------

it('splits on \\n', () => {
expect(texts([chunk('hello\nworld\n')])).toEqual(['hello', 'world']);
});

it('keeps a trailing line that has no terminating newline', () => {
expect(texts([chunk('hello\nworld')])).toEqual(['hello', 'world']);
});

it('handles empty input', () => {
expect(texts([])).toEqual([]);
});

it('handles a single chunk with no newlines', () => {
expect(texts([chunk('hello')])).toEqual(['hello']);
});

// ---------------------------------------------------------------------------
// \\r\\n (CRLF) handling
// ---------------------------------------------------------------------------

it('treats \\r\\n as a single newline', () => {
expect(texts([chunk('hello\r\nworld\r\n')])).toEqual(['hello', 'world']);
});

it('handles \\r\\n split across two chunks', () => {
expect(texts([chunk('hello\r'), chunk('\nworld')])).toEqual(['hello', 'world']);
});

// ---------------------------------------------------------------------------
// Bare \\r (carriage return) — progress bar behavior
// ---------------------------------------------------------------------------

it('bare \\r overwrites the current line (single chunk)', () => {
expect(texts([chunk('progress 50%\rprogress 100%\n')])).toEqual(['progress 100%']);
});

it('bare \\r overwrites across multiple updates in one chunk', () => {
expect(texts([chunk('10%\r20%\r30%\r40%\n')])).toEqual(['40%']);
});

it('bare \\r overwrites across separate chunks', () => {
expect(texts([chunk('downloading 50%\r'), chunk('downloading 100%\n')])).toEqual([
'downloading 100%',
]);
});

it('bare \\r at end of chunk followed by non-\\n text in next chunk overwrites', () => {
expect(texts([chunk('old text\r'), chunk('new text')])).toEqual(['new text']);
});

it('multiple progress updates across separate chunks collapse correctly', () => {
expect(texts([chunk('10%\r'), chunk('20%\r'), chunk('30%\r'), chunk('done\n')])).toEqual([
'done',
]);
});

it('progress bar followed by normal output', () => {
expect(texts([chunk('building...\rprogress 50%\rprogress 100%\nSuccess!\n')])).toEqual([
'progress 100%',
'Success!',
]);
});

// ---------------------------------------------------------------------------
// Mixed scenarios
// ---------------------------------------------------------------------------

it('handles normal lines before and after progress bars', () => {
expect(
texts([chunk('Starting build\n'), chunk('0%\r50%\r100%\n'), chunk('Build complete\n')])
).toEqual(['Starting build', '100%', 'Build complete']);
});

it('handles interleaved stdout and stderr', () => {
const result = processChunksToLines([
chunk('out line\n', 'stdout'),
chunk('err line\n', 'stderr'),
]);
expect(result).toEqual([
{ text: 'out line', stream: 'stdout' },
{ text: 'err line', stream: 'stderr' },
]);
});

it('bare \\r at the very end of all chunks produces no trailing line', () => {
// A progress bar that never finishes with \n — should show nothing
// because the \r resets the line and there's no further content.
expect(texts([chunk('in progress\r')])).toEqual([]);
});

it('empty lines are preserved', () => {
expect(texts([chunk('a\n\nb\n')])).toEqual(['a', '', 'b']);
});

it('handles chunk boundaries mid-text', () => {
expect(texts([chunk('hel'), chunk('lo\nwor'), chunk('ld\n')])).toEqual(['hello', 'world']);
});
});
98 changes: 98 additions & 0 deletions apps/staged/src/lib/features/actions/processOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Terminal output processing utilities.
*
* Converts raw output chunks (as received from the backend) into display-ready
* terminal lines, handling carriage returns the way a real terminal would.
*/

import type { OutputChunk } from './actions';

/** A processed terminal line ready for display. */
export interface TerminalLine {
text: string;
stream: 'stdout' | 'stderr';
}

/**
* Process raw output chunks into terminal lines, handling carriage returns.
*
* Terminal programs use \r (carriage return without newline) to overwrite the
* current line in-place — e.g. for progress bars. This function simulates
* that behavior:
* - \n finalizes the current line and starts a new one
* - \r\n is treated as a single newline
* - \r (not followed by \n) resets the cursor to the start of the current
* line so subsequent text overwrites it
*
* Because chunks arrive in arbitrary byte boundaries, \r\n may be split across
* two consecutive chunks. We track this with `pendingCR` so the \r at the end
* of one chunk and the \n at the start of the next are still treated as a
* single newline.
*/
export function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] {
const lines: TerminalLine[] = [];
let currentText = '';
let currentStream: 'stdout' | 'stderr' = 'stdout';
let pendingCR = false;

for (const chunk of chunks) {
const raw = chunk.chunk;
const stream = chunk.stream;

for (let i = 0; i < raw.length; i++) {
const ch = raw[i];

if (pendingCR) {
pendingCR = false;
if (ch === '\n') {
// \r\n split across chunks — treat as a single newline
lines.push({ text: currentText, stream: currentStream });
Comment on lines +45 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate split-CRLF handling by stream

The pendingCR branch currently treats any next \n as the completion of a prior \r, even when that \n comes from a different stream chunk. In mixed stdout/stderr output (still possible when chunks are interleaved from separate streams), a stdout chunk ending in \r followed by a stderr chunk starting with \n will be misinterpreted as CRLF and incorrectly preserve/finalize the stdout line instead of applying carriage-return overwrite semantics. The CRLF-bridging logic should only trigger when the continuation chunk is from the same stream as the pending \r.

Useful? React with 👍 / 👎.

currentText = '';
currentStream = stream;
continue;
} else {
// The previous \r was a bare carriage return — reset the line
currentText = '';
currentStream = stream;
}
}

if (ch === '\n') {
lines.push({ text: currentText, stream: currentStream });
currentText = '';
currentStream = stream;
} else if (ch === '\r') {
if (i + 1 < raw.length && raw[i + 1] === '\n') {
// \r\n within the same chunk
lines.push({ text: currentText, stream: currentStream });
currentText = '';
currentStream = stream;
i++; // skip the \n
} else if (i + 1 < raw.length) {
// Bare \r with more data in this chunk: reset cursor (overwrite)
currentText = '';
currentStream = stream;
} else {
// \r at the very end of the chunk — defer decision until next chunk
pendingCR = true;
}
} else {
currentText += ch;
currentStream = stream;
}
}
}

// If the last chunk ended with a bare \r that was never resolved, treat it
// as a carriage return (reset the line).
if (pendingCR) {
currentText = '';
}

// Don't forget the last in-progress line
if (currentText.length > 0) {
lines.push({ text: currentText, stream: currentStream });
}

return lines;
}
7 changes: 7 additions & 0 deletions apps/staged/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
Loading