fix: trim trailing blank lines BEFORE slice, not after

Sessions with cursor near the top (e.g. a fresh tunnel/ssh session)
have content at rows 1-2 and rows 3-40 blank in a 40-row terminal.
The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line
snapshot grabs the last 20 rows — all blank. Trimming after slice then
removed everything, leaving an empty <pre>.

Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.

Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
This commit is contained in:
Brian Krabach
2026-04-01 08:59:54 -07:00
parent 96da24c30b
commit 1dcf1fc2f3
2 changed files with 68 additions and 14 deletions
+16 -14
View File
@@ -526,14 +526,15 @@ function buildTileHTML(session, index, mobile) {
// Last N lines of snapshot — show more in fit mode so tall tiles fill // Last N lines of snapshot — show more in fit mode so tall tiles fill
const snapshot = session.snapshot || ''; const snapshot = session.snapshot || '';
var _lineCount = (ds.viewMode === 'fit') ? -80 : -20; var _lineCount = (ds.viewMode === 'fit') ? -80 : -20;
var lines = snapshot.split('\n').slice(_lineCount); // Trim trailing blank lines from the FULL snapshot FIRST — sessions with the cursor
// Trim trailing blank lines — sessions with cursor near the top have mostly empty // near the top (e.g. fresh tunnel/ssh session) have content at rows 1-2 and rows 3-40
// lines at the bottom, making the preview appear blank. Removing them lets the // blank. slice(-20) would grab the last 20 rows (all blank); trimming after slice
// CSS `position: absolute; bottom: 0` anchor the meaningful content correctly. // then removes everything → empty preview. Trim first so slice sees only content rows.
while (lines.length > 0 && lines[lines.length - 1].trim() === '') { var allLines = snapshot.split('\n');
lines.pop(); while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
allLines.pop();
} }
const lastLines = lines.join('\n'); const lastLines = allLines.slice(_lineCount).join('\n');
const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : ''; const sourceUrlAttr = session.sourceUrl ? ` data-source-url="${escapeHtml(session.sourceUrl)}"` : '';
return ( return (
@@ -582,15 +583,16 @@ function buildSidebarHTML(session, currentSession) {
// Timestamp for meta line // Timestamp for meta line
const timeStr = formatTimestamp(session.last_activity_at || null); const timeStr = formatTimestamp(session.last_activity_at || null);
// Last 20 lines of snapshot // Last 20 lines of snapshot — trim trailing blanks from the FULL snapshot FIRST,
// then slice. Sessions with the cursor near the top have content at rows 1-2 and
// rows 3-40 blank; slice(-20) would return only blank rows, then trim-after-slice
// removes everything → empty preview. Trim first to keep meaningful content.
const snapshot = session.snapshot || ''; const snapshot = session.snapshot || '';
var sidebarLines = snapshot.split('\n').slice(-20); var allLines = snapshot.split('\n');
// Trim trailing blank lines — sessions with cursor near the top have mostly empty while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
// lines at the bottom, making the preview appear blank. allLines.pop();
while (sidebarLines.length > 0 && sidebarLines[sidebarLines.length - 1].trim() === '') {
sidebarLines.pop();
} }
const lastLines = sidebarLines.join('\n'); const lastLines = allLines.slice(-20).join('\n');
return ( return (
`<article class="${classes}" data-session="${escapedName}" data-source-url="${escapeHtml(session.sourceUrl || '')}" tabindex="0" role="listitem">` + `<article class="${classes}" data-session="${escapedName}" data-source-url="${escapeHtml(session.sourceUrl || '')}" tabindex="0" role="listitem">` +
+52
View File
@@ -4354,3 +4354,55 @@ test('buildSidebarHTML renders empty pre when snapshot is entirely blank lines',
assert.ok(preMatch, '<pre> element must exist'); assert.ok(preMatch, '<pre> element must exist');
assert.strictEqual(preMatch[1], '', 'sidebar pre should be empty when snapshot has only blank lines'); assert.strictEqual(preMatch[1], '', 'sidebar pre should be empty when snapshot has only blank lines');
}); });
// --- Trim BEFORE slice: 40-row terminal with content at top ---
// The real bug: a 40-row terminal with content at rows 1-2 and rows 3-40 blank.
// slice(-20) grabs the LAST 20 rows → all blank. Then trim-after-slice removes
// everything → empty pre. Fix: trim trailing blanks from the full snapshot FIRST,
// then slice the last 20 of what remains.
test('buildTileHTML shows content from top of 40-row terminal (trim BEFORE slice)', () => {
// 40 lines: 2 content lines + 38 trailing blank lines (realistic 40-row terminal,
// cursor near top — e.g. a fresh ssh tunnel session)
const snapshot = 'TUNNEL_CONTENT_LINE\nstatus: connected\n' + '\n'.repeat(38);
const session = { name: 'tunnel', snapshot };
const html = app.buildTileHTML(session, 0, false);
assert.ok(
html.includes('TUNNEL_CONTENT_LINE'),
'tile must show content from top of terminal even with 38 trailing blank lines — trim full snapshot BEFORE slice(-20)',
);
});
test('buildSidebarHTML shows content from top of 40-row terminal (trim BEFORE slice)', () => {
const snapshot = 'TUNNEL_CONTENT_LINE\nstatus: connected\n' + '\n'.repeat(38);
const session = { name: 'tunnel', snapshot, bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(
html.includes('TUNNEL_CONTENT_LINE'),
'sidebar must show content from top of terminal even with 38 trailing blank lines — trim full snapshot BEFORE slice(-20)',
);
});
test('buildTileHTML trim happens BEFORE slice in source (structural order check)', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function buildTileHTML');
const fnBody = source.substring(fnStart, fnStart + 2000);
const trimIdx = fnBody.indexOf('.pop()');
const sliceIdx = fnBody.indexOf('.slice(');
assert.ok(
trimIdx < sliceIdx,
'trailing blank trim (.pop()) must appear BEFORE .slice() in buildTileHTML — trim the full snapshot first, then slice the last N lines',
);
});
test('buildSidebarHTML trim happens BEFORE slice in source (structural order check)', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const fnStart = source.indexOf('function buildSidebarHTML');
const fnBody = source.substring(fnStart, fnStart + 2000);
const trimIdx = fnBody.indexOf('.pop()');
const sliceIdx = fnBody.indexOf('.slice(');
assert.ok(
trimIdx < sliceIdx,
'trailing blank trim (.pop()) must appear BEFORE .slice() in buildSidebarHTML — trim the full snapshot first, then slice the last 20 lines',
);
});