diff --git a/vscode-appdaemon/media/logExplorer.css b/vscode-appdaemon/media/logExplorer.css index b767e2b..cc804bc 100644 --- a/vscode-appdaemon/media/logExplorer.css +++ b/vscode-appdaemon/media/logExplorer.css @@ -12,3 +12,4 @@ .last-log{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} summary{display:grid;grid-template-columns:auto minmax(0,1fr) repeat(3,max-content);align-items:center;column-gap:10px;row-gap:5px} summary .last-log{grid-column:1/-1;min-width:0} +.rows{max-height:min(45vh,420px);overflow-y:auto;scrollbar-gutter:stable;overflow-anchor:none} diff --git a/vscode-appdaemon/media/logExplorer.js b/vscode-appdaemon/media/logExplorer.js index aaab556..1089fc4 100644 --- a/vscode-appdaemon/media/logExplorer.js +++ b/vscode-appdaemon/media/logExplorer.js @@ -16,6 +16,7 @@ let entries = [], latest, appliedMessage, displayZone = 'Europe/Berlin', paused = false, selected = saved.selected, mode = saved.mode || 'graph', origin = saved.origin || saved.selected; const expanded = new Set(saved.expanded || []); const visibleEventGroups = new Map(); + const logScrollPositions = new Map(); const recentEvents = new Set(); const recentApps = new Set(); const appFlashTokens = new Map(); @@ -138,6 +139,16 @@ }); } function renderApps() { + const appList = $('apps').parentElement; + const appListTop = appList.scrollTop; + document.querySelectorAll('#apps details[data-app]').forEach(detail => { + const rows = detail.querySelector('.rows'); + if (!rows) return; + logScrollPositions.set(detail.dataset.app, { + top: rows.scrollTop, + follow: rows.scrollHeight - rows.clientHeight - rows.scrollTop <= 24 + }); + }); visibleEventGroups.clear(); const container = $('apps'); container.replaceChildren(); const groups = new Map(); @@ -195,15 +206,23 @@ ? (entries.find(e => e.app === app && e.initialization_id === unit.id && e.kind === 'initialization_started') || unit.entries[0]).time : unit.entries[0].time; units.sort((a, b) => startTime(a) - startTime(b)); + if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more')); units.slice(-150).forEach(unit => { appendEventRows(rows, unit.entries, unit.id ? `initialization:${unit.id}` : undefined); }); - if (units.length > 150) rows.append(node('div', `${units.length - 150} older rows/groups: narrow the time range or search.`, 'more')); detail.append(rows); + const position = logScrollPositions.get(app); + rows.scrollTop = !position || position.follow ? rows.scrollHeight : position.top; } - detail.addEventListener('toggle', event => { if (event.target !== detail) return; detail.open ? expanded.add(app) : expanded.delete(app); save(); fill(); }); - fill(); container.append(detail); + detail.addEventListener('toggle', event => { + if (event.target !== detail) return; + if (detail.open) expanded.add(app); + else { expanded.delete(app); logScrollPositions.delete(app); } + save(); fill(); + }); + container.append(detail); fill(); }); + appList.scrollTop = appListTop; } function renderDetail() { $('graph-mode').setAttribute('aria-pressed', String(mode === 'graph')); diff --git a/vscode-appdaemon/tests/logUi.test.js b/vscode-appdaemon/tests/logUi.test.js index b029020..dd1ef70 100644 --- a/vscode-appdaemon/tests/logUi.test.js +++ b/vscode-appdaemon/tests/logUi.test.js @@ -25,7 +25,8 @@ test('English interface, latest timestamps, sorting and separate initialization assert.match(d.querySelector('[data-app="alpha"] .last-age').title,/10:00:10/); assert.match(d.querySelector('[data-app="alpha"] summary .last-log').textContent,/ : Température déjà disponible$/); const summary=d.querySelector('[data-app="alpha"] summary'); - assert.deepEqual([...summary.children].map(child=>child.className),['app-label','badge INFO','badge WARNING','badge ERROR','last-log']); + assert.deepEqual([...summary.children].slice(0,4).map(child=>child.className),['app-label','badge INFO','badge WARNING','badge ERROR']); + assert.ok(summary.children[4].classList.contains('last-log')); assert.equal(summary.querySelector('.app-label .last-log'),null); d.getElementById('sort').value='oldest';d.getElementById('sort').dispatchEvent(new dom.window.Event('input')); assert.equal(d.querySelector('[data-app]').dataset.app,'alpha'); @@ -122,6 +123,35 @@ test('pulses an app when a new log arrives and refreshes its last-log age',async dom.window.close(); }); +test('expanded logs follow new events at the bottom and keep position while reading history',async()=>{ + const {dom,d,entries,send}=setup(); + Object.defineProperty(dom.window.HTMLElement.prototype,'scrollHeight',{configurable:true,get(){return this.classList.contains('rows') ? this.children.length*100 : 0;}}); + Object.defineProperty(dom.window.HTMLElement.prototype,'clientHeight',{configurable:true,get(){return this.classList.contains('rows') ? 100 : 0;}}); + const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); + const rows=()=>d.querySelector('[data-app="alpha"] .rows'); + assert.equal(rows().scrollTop,rows().scrollHeight); + const first={...entries[3],event_id:'new-tail',time:entries[3].time+1000,message:'Newest'}; + send([...entries,first]); + assert.equal(rows().scrollTop,rows().scrollHeight); + rows().scrollTop=20; + const second={...entries[3],event_id:'new-tail-2',time:entries[3].time+2000,message:'Newer'}; + send([...entries,first,second]); + assert.equal(rows().scrollTop,20); + assert.match(rows().textContent,/Newer/); + dom.window.close(); +}); + +test('the newest log stays at the bottom when older groups are omitted',()=>{ + const {dom,d,entries,send}=setup(); + const base=entries[3]; + send(Array.from({length:152},(_,index)=>({...base,event_id:`event-${index}`,time:base.time+index*1000,message:`Message ${index}`}))); + const app=d.querySelector('[data-app="alpha"]');app.open=true;app.dispatchEvent(new dom.window.Event('toggle')); + const rows=app.querySelector('.rows'); + assert.ok(rows.firstElementChild.classList.contains('more')); + assert.match(rows.lastElementChild.textContent,/Message 151/); + dom.window.close(); +}); + test('distinguishes log lines from supplemental records when configured',()=>{ const {dom,d,entries,send}=setup(); const trace={...entries[3],event_id:'trace-evaluation',kind:'evaluation',message:'Condition was evaluated'}; diff --git a/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix b/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix index f27ab5e..dc730f6 100644 Binary files a/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix and b/vscode-appdaemon/vscode-appdaemon-0.2.0.vsix differ