From 8aadf6dd5bdc2c74b26e18f7f073833d3abf9f10 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Sat, 14 Nov 2026 10:45:26 +0100 Subject: [PATCH] Show saved workspace presets in the layout menu --- .../Terminal-and-Persistent-tmux-Sessions.md | 2 +- static/css/terminal-windows.css | 12 + static/js/customer-workflows-i18n.js | 12 + static/js/customer-workflows.js | 107 ++++++- templates/index.html | 1 + tests/e2e/workspace-preset-dropdown.spec.js | 262 ++++++++++++++++++ tests/js/customer-workflows.test.js | 16 ++ 7 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/workspace-preset-dropdown.spec.js diff --git a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md index fdd1a281..2c7f51f6 100644 --- a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md +++ b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md @@ -315,6 +315,6 @@ Run `node tests/integration/tmux_sync.cjs` after installing the project Node dep ## Named workspace presets -Open **Layout → Workspace presets** in the workspace toolbar, on desktop or mobile. Save a name, one/two/four panes and up to that many of your saved hosts. Presets belong to your account and retain only layout and host references, never passwords, commands or terminal output. Applying prepares the layout and offers an explicit Connect button for each host; it never logs in or executes commands by itself. Existing assigned sessions stay in place, and a smaller preset does not shrink their layout. More than four open floating windows must be docked or reduced before applying a preset. Deleted hosts are shown as unavailable; remove the affected preset and save updated choices. +Open **Layout → Workspace presets** in the workspace toolbar, on desktop or mobile. Save a name, one/two/four panes and up to that many of your saved hosts. Saved presets also appear by name directly in **Layout**; select one to apply its pane layout. The menu refreshes when opened and reflects saved or removed presets immediately. Presets belong to your account and retain only layout and host references, never passwords, commands or terminal output. Use the preset manager to apply a layout and reveal an explicit Connect button for each host. Neither action logs in or executes commands by itself. Existing assigned sessions remain connected; smaller layouts compact occupied panes and retain enough panes for every assigned session. More than four open floating windows must be docked or reduced before applying a preset. Deleted hosts are shown as unavailable in the manager; remove the affected preset and save updated choices. Reconnection never proves that a previous command succeeded; inspect the remote state before repeating work. diff --git a/static/css/terminal-windows.css b/static/css/terminal-windows.css index 54de2d0d..103d6b8c 100644 --- a/static/css/terminal-windows.css +++ b/static/css/terminal-windows.css @@ -518,6 +518,7 @@ body[data-theme] .ws-window .terminal-pane.active { top: calc(100% + 8px); z-index: 1100; width: 230px; + max-width: calc(100vw - 24px); max-height: calc(100dvh - 120px); overflow-y: auto; padding: 8px; @@ -529,6 +530,7 @@ body[data-theme] .ws-window .terminal-pane.active { .workspace-layout-options .split-controls, .workspace-layout-options .window-arrange-controls, +.workspace-preset-options, .workspace-layout-presets { display: grid; gap: 4px; @@ -555,6 +557,16 @@ body[data-theme] .ws-window .terminal-pane.active { } .workspace-layout-options .material-icons { font-size: 18px; } +.workspace-preset-btn .material-icons { flex: 0 0 auto; } +.workspace-preset-name { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} +.workspace-preset-options .tab-row-icon-btn:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: -2px; +} .workspace-layout-hint { padding: 6px 10px; color: var(--text-muted); diff --git a/static/js/customer-workflows-i18n.js b/static/js/customer-workflows-i18n.js index 89a29e5f..a300ec28 100644 --- a/static/js/customer-workflows-i18n.js +++ b/static/js/customer-workflows-i18n.js @@ -8,6 +8,8 @@ "customer.recoverySchedulesTitle": "Review schedules", "customer.recoveryTestTitle": "Test a connection", "customer.noPresets": "No presets yet. Save a layout and host selection below.", + "customer.noSavedPresets": "No saved presets.", + "customer.retry": "Retry", "customer.newPreset": "New preset", "customer.presetSaved": "Preset saved.", @@ -63,6 +65,8 @@ "customer.reloadRecovery": "Reload recovery checklist" }, "de": { + "customer.noSavedPresets": "Keine gespeicherten Presets.", + "customer.retry": "Erneut versuchen", "customer.recoveryKeysTitle": "Zugangsdaten ergänzen", "customer.recoveryHostsTitle": "Hosts und Vertrauen prüfen", "customer.recoverySchedulesTitle": "Zeitpläne prüfen", @@ -123,6 +127,8 @@ "customer.reloadRecovery": "Checkliste neu laden" }, "fr": { + "customer.noSavedPresets": "Aucun préréglage enregistré.", + "customer.retry": "Réessayer", "customer.recoveryKeysTitle": "Rétablir les identifiants", "customer.recoveryHostsTitle": "Vérifier les hôtes et leur confiance", "customer.recoverySchedulesTitle": "Vérifier les planifications", @@ -183,6 +189,8 @@ "customer.reloadRecovery": "Recharger la liste" }, "es": { + "customer.noSavedPresets": "No hay preajustes guardados.", + "customer.retry": "Reintentar", "customer.recoveryKeysTitle": "Restablecer credenciales", "customer.recoveryHostsTitle": "Revisar hosts y confianza", "customer.recoverySchedulesTitle": "Revisar programaciones", @@ -243,6 +251,8 @@ "customer.reloadRecovery": "Recargar lista de recuperación" }, "vi": { + "customer.noSavedPresets": "Chưa có bố cục được lưu.", + "customer.retry": "Thử lại", "customer.recoveryKeysTitle": "Khôi phục thông tin đăng nhập", "customer.recoveryHostsTitle": "Kiểm tra máy chủ và độ tin cậy", "customer.recoverySchedulesTitle": "Kiểm tra lịch chạy", @@ -303,6 +313,8 @@ "customer.reloadRecovery": "Tải lại danh sách khôi phục" }, "zh": { + "customer.noSavedPresets": "暂无已保存的预设。", + "customer.retry": "重试", "customer.recoveryKeysTitle": "恢复凭据", "customer.recoveryHostsTitle": "检查主机及信任", "customer.recoverySchedulesTitle": "检查计划", diff --git a/static/js/customer-workflows.js b/static/js/customer-workflows.js index 403b007e..c4a5b83c 100644 --- a/static/js/customer-workflows.js +++ b/static/js/customer-workflows.js @@ -8,8 +8,7 @@ const assigned = [...new Set(existing.filter(Boolean))]; if (assigned.length > 4) throw new RangeError('Too many assigned sessions'); const needed = [1, 2, 4].find(count => count >= assigned.length); - const layout = Math.max([1, 2, 4].includes(requested) ? requested : 1, - [1, 2, 4].includes(current) ? current : needed, needed); + const layout = Math.max([1, 2, 4].includes(requested) ? requested : 1, needed); const slots = current === 'windows' || existing.slice(layout).some(Boolean) ? assigned : existing; return {layout, assignments: Array.from({length: layout}, (_, index) => slots[index] || null)}; } @@ -101,30 +100,114 @@ }catch(error){loading.remove();report(modal,error);} } + const picker=document.getElementById('workspaceLayoutPicker'); + const presetOptions=document.getElementById('workspacePresetOptions'); + let savedPresets=[]; + let presetState='idle'; + let presetError=null; + let presetLoad=null; + let presetRevision=0; + function applyPreset(preset) { + if(!sessions) return false; + let next; + try { next=layoutAssignments(preset.layout,sessions.paneAssignments,sessions.layout); } + catch { throw Error(t('extraWindowLayout')); } + sessions.applyPaneAssignments(next.layout,next.assignments); + document.getElementById('workspaceNavBtn')?.click(); + return true; + } + function renderPresetOptions() { + if(!presetOptions) return; + presetOptions.replaceChildren(); + if(presetState!=='error') { + for(const preset of savedPresets) { + const item=button('',event=>{ + // Rendering can remove the click target before the outside-click handler runs. + event.stopPropagation(); + try { + if(!applyPreset(preset)) return; + picker.open=false; + picker.querySelector('summary')?.focus(); + } catch(error) { + presetError=error; + renderPresetOptions(); + const alert=presetOptions.querySelector('[role="alert"]'); + alert?.focus({preventScroll:true}); + alert?.scrollIntoView({block:'nearest'}); + } + }); + item.className='tab-row-icon-btn workspace-preset-btn'; + item.dataset.workspacePresetId=preset.id; + item.disabled=presetState==='loading'; + const icon=el('span','view_quilt','material-icons');icon.setAttribute('aria-hidden','true'); + item.append(icon,el('span',preset.name,'workspace-preset-name')); + presetOptions.append(item); + } + } + if(presetState==='loading' || (presetState==='ready' && !savedPresets.length)) { + const status=el('p',t(presetState==='loading'?'loading':'noSavedPresets'),'workspace-layout-hint'); + status.role='status';presetOptions.append(status); + } + if(presetError) { + const alert=el('p',presetError.message,'workspace-layout-hint'); + alert.role='alert';alert.tabIndex=-1;presetOptions.append(alert); + } + if(presetState==='error') { + const retry=button(t('retry'),async event=>{ + event.stopPropagation(); + try { await loadPresets(); } catch { /* The menu displays the load error. */ } + if(picker.open) (presetOptions.querySelector('button:not(:disabled)') || document.getElementById('workspacePresetsBtn'))?.focus(); + }); + retry.className='tab-row-icon-btn';presetOptions.append(retry); + } + } + function loadPresets() { + if(presetLoad) return presetLoad; + const revision=presetRevision; + presetState='loading';presetError=null;renderPresetOptions(); + presetLoad=(async()=>{ + try { + const data=await api('workspace-presets'); + // A completed save takes precedence over an older pending read. + if(revision===presetRevision) {savedPresets=data.presets;presetState='ready';} + return savedPresets; + } catch(error) { + if(revision!==presetRevision) return savedPresets; + presetState='error';presetError=error;throw error; + } finally { + presetLoad=null; + if(revision===presetRevision) renderPresetOptions(); + } + })(); + return presetLoad; + } + picker?.addEventListener('toggle',()=>{ + if(picker.open) loadPresets().catch(()=>{ /* The built-in layouts remain available. */ }); + }); async function openPresets() { - const picker=document.getElementById('workspaceLayoutPicker'); if(picker?.open){picker.open=false;picker.querySelector('summary')?.focus();} const modal=dialog(t('presetsTitle'));modal.body.append(el('p',t('presetsHint'),'customer-intro')); const loading=el('p',t('loading'),'customer-muted');loading.role='status';modal.body.append(loading); let presets; - try {presets=(await api('workspace-presets')).presets;}catch(error){loading.remove();report(modal,error);return;} + try {presets=await loadPresets();}catch(error){loading.remove();if(modal.isConnected)report(modal,error);return;} loading.remove(); if(!modal.isConnected) return; const saved=el('div','','customer-presets');modal.body.append(saved); - async function save(next) { const data=await api('workspace-presets',{presets:next}); presets=data.presets; render(); } + async function save(next) { + const data=await api('workspace-presets',{presets:next}); + presetRevision++; + presets=data.presets;savedPresets=presets;presetState='ready';presetError=null; + renderPresetOptions();render(); + } function render() { saved.replaceChildren();if(!presets.length)saved.append(el('p',t('noPresets'),'customer-empty')); for(const preset of presets) { const row=el('section','', 'customer-row');const info=el('div','','customer-preset-info');info.append(el('h3',preset.name),el('p',t('layout')+': '+preset.layout,'customer-muted'));row.append(info);const actions=el('div','','customer-row-actions'); const choices=el('div','','customer-preset-connections'); choices.hidden=true; const apply=button(t('applyLayout'),()=>{ - const manager=sessions; if(!manager)return; - if(manager.windowMode && manager.paneAssignments.filter(Boolean).length>4){report(modal,Error(t('extraWindowLayout')));return;} - let next; - try { next=layoutAssignments(preset.layout,manager.paneAssignments,manager.layout); } - catch { report(modal,Error(t('extraWindowLayout')));return; } - manager.applyPaneAssignments(next.layout,next.assignments); - document.getElementById('workspaceNavBtn')?.click(); choices.hidden=false;apply.setAttribute('aria-expanded','true'); + try { if(!applyPreset(preset))return; } + catch(error) {report(modal,error);return;} + choices.hidden=false;apply.setAttribute('aria-expanded','true'); });apply.setAttribute('aria-expanded','false');actions.append(apply); for(const id of preset.profile_ids) { const profile=root.ProfileManager?.profiles.find(item=>item.id===id); diff --git a/templates/index.html b/templates/index.html index 072b56c5..38e3bd4c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -180,6 +180,7 @@

Drag a title bar to an edge to snap; to a corner for a quarter.

+
diff --git a/tests/e2e/workspace-preset-dropdown.spec.js b/tests/e2e/workspace-preset-dropdown.spec.js new file mode 100644 index 00000000..07d66ca5 --- /dev/null +++ b/tests/e2e/workspace-preset-dropdown.spec.js @@ -0,0 +1,262 @@ +const {test, expect} = require('playwright/test'); +const {login, openWorkspace, installSshConnectTrap, assertNoExternalRequests} = require('./helpers'); +const path = require('node:path'); +const fs = require('node:fs'); + +test.afterEach(async ({page}) => assertNoExternalRequests(page)); + +async function savePresets(page, presets) { + const response = await page.request.put('/api/account/workspace-presets', { + headers: {'X-CSRFToken': await page.locator('meta[name="csrf-token"]').getAttribute('content')}, + data: {presets} + }); + expect(response.ok()).toBe(true); +} + +async function addExistingSessions(page, count = 1) { + await page.evaluate(count => { + const original = window.socket.emit.bind(window.socket); + window.__presetSessionEvents = []; + window.socket.emit = (event, payload, ...rest) => { + if (payload?.session_id?.startsWith('preset-live-')) { + window.__presetSessionEvents.push(event); + return window.socket; + } + return original(event, payload, ...rest); + }; + for (let index = 0; index < count; index++) { + const id = 'preset-live-' + index; + window.socket.listeners('ssh_connected').forEach(listener => listener({ + session_id: id, host: 'lab.example', port: 22, username: 'ops', + client_request_id: 'reconnect_' + id + })); + TerminalManager.writeOutput(id, 'ops@lab:~$ '); + } + window.__presetTerminal = document.getElementById('terminal-preset-live-0'); + }, count); +} + +for (const width of [1440, 390]) { + test(`named presets apply directly by keyboard and preserve terminals at ${width}px`, async ({page}) => { + await page.setViewportSize({width, height: 900}); + await login(page); + await installSshConnectTrap(page); + const host = await page.evaluate(() => ProfileManager.profiles[0].id); + const name = ' ' + 'Workspace'.repeat(5); + await savePresets(page, [ + {id: 'single', name, layout: 1, profile_ids: [host]}, + {id: 'split', name: 'Operations split', layout: 2, profile_ids: []} + ]); + const picker = page.locator('#workspaceLayoutPicker'); + const summary = picker.locator('summary'); + await summary.click(); + await picker.locator('.split-btn[data-layout="4"]').click(); + await summary.focus(); + await page.keyboard.press('Enter'); + const preset = picker.getByRole('button', {name, exact: true}); + await expect(preset).toBeVisible(); + await expect(picker.locator('#workspacePresetOptions img')).toHaveCount(0); + await expect(picker.getByRole('button', {name: 'Operations split', exact: true})).toBeVisible(); + await preset.focus(); + const bounds = await preset.boundingBox(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(width); + expect(await preset.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true); + const folder = path.join(process.cwd(), 'plans', 'workspace-preset-dropdown'); + fs.mkdirSync(folder, {recursive: true}); + await page.screenshot({path: path.join(folder, `dropdown-${width}.png`)}); + await page.keyboard.press('Enter'); + await expect(picker).not.toHaveAttribute('open', ''); + await expect(summary).toBeFocused(); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await page.evaluate(() => SessionManager.layout)).toBe(1); + + await page.evaluate(() => SessionManager.applyPaneAssignments(4, [null, null, null, null])); + await addExistingSessions(page); + await page.evaluate(() => SessionManager.applyPaneAssignments(4, [null, null, null, 'preset-live-0'])); + await summary.click(); + await expect(preset).toBeEnabled(); + await preset.click(); + await expect(page.locator('#terminal-preset-live-0')).toBeVisible(); + expect(await page.evaluate(() => ({ + layout: SessionManager.layout, + slots: SessionManager.paneAssignments, + sameTerminal: window.__presetTerminal === document.getElementById('terminal-preset-live-0'), + sessionEvents: window.__presetSessionEvents.filter(event => ['ssh_disconnect', 'ssh_input'].includes(event)), + connections: window.__sshConnectAttempts + }))).toEqual({layout: 1, slots: ['preset-live-0'], sameTerminal: true, sessionEvents: [], connections: []}); + await summary.click(); + await page.keyboard.press('Escape'); + await expect(summary).toBeFocused(); + await expect(picker).not.toHaveAttribute('open', ''); + }); +} + +test('saving, reloading, removing and other-tab changes update dropdown presets', async ({page}) => { + await login(page); + await savePresets(page, []); + const picker = page.locator('#workspaceLayoutPicker'); + const options = page.locator('#workspacePresetOptions'); + await picker.locator('summary').click(); + await picker.locator('#workspacePresetsBtn').click(); + let dialog = page.getByRole('dialog', {name: 'Workspace presets', exact: true}); + await dialog.getByLabel('Preset name', {exact: true}).fill('Morning checks'); + await dialog.getByLabel('Panes', {exact: true}).selectOption('2'); + await dialog.getByRole('button', {name: 'Save preset', exact: true}).click(); + await expect(dialog.getByRole('status')).toHaveText('Preset saved.'); + await expect(options.locator('.workspace-preset-name')).toHaveText(['Morning checks']); + await dialog.getByRole('button', {name: 'Close', exact: true}).click(); + await page.reload(); + await openWorkspace(page); + await picker.locator('summary').click(); + await expect(options.getByRole('button', {name: 'Morning checks', exact: true})).toBeVisible(); + await picker.locator('#workspacePresetsBtn').click(); + dialog = page.getByRole('dialog', {name: 'Workspace presets', exact: true}); + await dialog.getByRole('button', {name: 'Remove preset', exact: true}).click(); + await expect(dialog.getByText('No presets yet. Save a layout and host selection below.')).toBeVisible(); + await expect(options.locator('button[data-workspace-preset-id]')).toHaveCount(0); + await dialog.getByRole('button', {name: 'Close', exact: true}).click(); + await savePresets(page, [{id: 'external', name: 'Saved in another tab', layout: 4, profile_ids: []}]); + await picker.locator('summary').click(); + await expect(options.getByRole('button', {name: 'Saved in another tab', exact: true})).toBeVisible(); + await picker.locator('summary').click(); + await savePresets(page, [{id: 'external', name: 'Renamed elsewhere', layout: 1, profile_ids: []}]); + await picker.locator('summary').click(); + await expect(options.getByRole('button', {name: 'Renamed elsewhere', exact: true})).toBeVisible(); + await expect(options.getByRole('button', {name: 'Saved in another tab', exact: true})).toHaveCount(0); +}); + +test('failed preset loading offers retry while built-in layouts remain usable', async ({page}) => { + await login(page); + let fail = true; + await page.route('**/api/account/workspace-presets', route => route.fulfill(fail + ? {status: 503, json: {error: 'Temporarily unavailable'}} + : {json: {presets: [{id: 'retry', name: 'Recovered preset', layout: 2, profile_ids: []}]}} + )); + const picker = page.locator('#workspaceLayoutPicker'); + await picker.locator('summary').click(); + await expect(picker.getByRole('alert')).toBeVisible(); + await picker.locator('.split-btn[data-layout="4"]').click(); + expect(await page.evaluate(() => SessionManager.layout)).toBe(4); + await picker.locator('summary').click(); + const retry = picker.getByRole('button', {name: 'Retry', exact: true}); + await expect(retry).toBeVisible(); + fail = false; + await retry.click(); + const preset = picker.getByRole('button', {name: 'Recovered preset', exact: true}); + await expect(preset).toBeVisible(); + await expect(preset).toBeFocused(); + await expect(picker.getByRole('alert')).toHaveCount(0); + await preset.click(); + expect(await page.evaluate(() => SessionManager.layout)).toBe(2); +}); + +test('pending refresh disables old presets until current account choices arrive', async ({page}) => { + await login(page); + await savePresets(page, [{id: 'old', name: 'Old choice', layout: 1, profile_ids: []}]); + const picker = page.locator('#workspaceLayoutPicker'); + await picker.locator('summary').click(); + await expect(picker.getByRole('button', {name: 'Old choice', exact: true})).toBeVisible(); + await picker.locator('summary').click(); + let release; + const pending = new Promise(resolve => { release = resolve; }); + await page.route('**/api/account/workspace-presets', async route => { + await pending; + await route.fulfill({json: {presets: [{id: 'new', name: 'Current choice', layout: 2, profile_ids: []}]}}); + }); + try { + await picker.locator('summary').click(); + await expect(picker.getByRole('status')).toBeVisible(); + await expect(picker.getByRole('button', {name: 'Old choice', exact: true})).toBeDisabled(); + } finally { + release(); + } + await expect(picker.getByRole('button', {name: 'Current choice', exact: true})).toBeEnabled(); + await expect(picker.getByRole('button', {name: 'Old choice', exact: true})).toHaveCount(0); +}); + +test('failed manager saves never add unsaved choices to the layout menu', async ({page}) => { + await login(page); + await savePresets(page, [{id: 'saved', name: 'Existing preset', layout: 1, profile_ids: []}]); + const picker = page.locator('#workspaceLayoutPicker'); + await picker.locator('summary').click(); + await expect(picker.getByRole('button', {name: 'Existing preset', exact: true})).toBeVisible(); + await picker.locator('#workspacePresetsBtn').click(); + const dialog = page.getByRole('dialog', {name: 'Workspace presets', exact: true}); + await dialog.getByLabel('Preset name', {exact: true}).fill('Unsaved choice'); + await page.route('**/api/account/workspace-presets', route => route.request().method() === 'PUT' + ? route.fulfill({status: 503, json: {error: 'Temporarily unavailable'}}) + : route.continue()); + await dialog.getByRole('button', {name: 'Save preset', exact: true}).click(); + await expect(dialog.getByRole('alert')).toBeVisible(); + await expect(dialog.getByRole('button', {name: 'Save preset', exact: true})).toBeEnabled(); + await dialog.getByRole('button', {name: 'Close', exact: true}).click(); + await picker.locator('summary').click(); + await expect(picker.getByRole('button', {name: 'Existing preset', exact: true})).toBeVisible(); + await expect(picker.getByRole('button', {name: 'Unsaved choice', exact: true})).toHaveCount(0); +}); + +test('a delayed refresh cannot replace a newly saved preset with old data', async ({page}) => { + await login(page); + const previous = [{id: 'existing', name: 'Existing choice', layout: 1, profile_ids: []}]; + await savePresets(page, previous); + const picker = page.locator('#workspaceLayoutPicker'); + const names = picker.locator('.workspace-preset-name'); + await picker.locator('summary').click(); + await picker.locator('#workspacePresetsBtn').click(); + const dialog = page.getByRole('dialog', {name: 'Workspace presets', exact: true}); + await dialog.getByLabel('Preset name', {exact: true}).fill('Newly saved choice'); + let releaseSave, releaseLoad, markLoadStarted; + const pendingSave = new Promise(resolve => { releaseSave = resolve; }); + const pendingLoad = new Promise(resolve => { releaseLoad = resolve; }); + const loadStarted = new Promise(resolve => { markLoadStarted = resolve; }); + await page.route('**/api/account/workspace-presets', async route => { + if (route.request().method() === 'PUT') { + await pendingSave; + await route.fulfill({json: {presets: route.request().postDataJSON().presets}}); + } else { + markLoadStarted(); + await pendingLoad; + await route.fulfill({json: {presets: previous}}); + } + }); + try { + await dialog.getByRole('button', {name: 'Save preset', exact: true}).click(); + await expect(dialog.getByRole('button', {name: 'Save preset', exact: true})).toBeDisabled(); + await dialog.getByRole('button', {name: 'Close', exact: true}).click(); + await picker.locator('summary').click(); + await loadStarted; + releaseSave(); + await expect(names).toHaveText(['Existing choice', 'Newly saved choice']); + const saved = picker.getByRole('button', {name: 'Newly saved choice', exact: true}); + await saved.focus(); + const response = page.waitForResponse(result => result.url().endsWith('/api/account/workspace-presets') && result.request().method() === 'GET'); + releaseLoad(); + await (await response).finished(); + await expect(saved).toBeVisible(); + await expect(saved).toBeFocused(); + await expect(names).toHaveText(['Existing choice', 'Newly saved choice']); + } finally { + releaseSave();releaseLoad(); + } +}); + +test('a preset refuses to discard more than four floating sessions', async ({page}) => { + await page.setViewportSize({width: 1440, height: 900}); + await login(page); + await installSshConnectTrap(page); + await savePresets(page, [{id: 'single', name: 'Single saved layout', layout: 1, profile_ids: []}]); + await page.evaluate(() => SessionManager.setWindowMode(true)); + await addExistingSessions(page, 5); + expect(await page.evaluate(() => SessionManager.paneAssignments.filter(Boolean).length)).toBe(5); + const before = await page.evaluate(() => SessionManager.paneAssignments); + const picker = page.locator('#workspaceLayoutPicker'); + await picker.locator('summary').click(); + await picker.getByRole('button', {name: 'Single saved layout', exact: true}).click(); + await expect(picker.getByRole('alert')).toContainText('more than four floating windows'); + await expect(picker.getByRole('alert')).toBeFocused(); + expect(await page.evaluate(() => SessionManager.paneAssignments)).toEqual(before); + expect(await page.evaluate(() => SessionManager.windowMode)).toBe(true); + expect(await page.evaluate(() => window.__sshConnectAttempts)).toEqual([]); + expect(await page.evaluate(() => window.__presetSessionEvents.filter(event => ['ssh_disconnect', 'ssh_input'].includes(event)))).toEqual([]); +}); diff --git a/tests/js/customer-workflows.test.js b/tests/js/customer-workflows.test.js index 4a50e395..ca6a209c 100644 --- a/tests/js/customer-workflows.test.js +++ b/tests/js/customer-workflows.test.js @@ -5,6 +5,22 @@ test('preset layout preserves existing assigned sessions and never connects', () assert.deepEqual(helpers.layoutAssignments(1, ['a', 'b'], 2), {layout: 2, assignments: ['a', 'b']}); assert.deepEqual(helpers.layoutAssignments(4, ['a', null], 2), {layout: 4, assignments: ['a', null, null, null]}); }); +test('single-pane presets shrink an empty quad layout', () => { + assert.deepEqual(helpers.layoutAssignments(1, [null, null, null, null], 4), { + layout: 1, assignments: [null] + }); +}); +test('smaller presets compact occupied later panes without losing sessions', () => { + assert.deepEqual(helpers.layoutAssignments(1, [null, null, null, 'a'], 4), { + layout: 1, assignments: ['a'] + }); + assert.deepEqual(helpers.layoutAssignments(1, [null, 'a', null, 'b'], 4), { + layout: 2, assignments: ['a', 'b'] + }); + assert.deepEqual(helpers.layoutAssignments(2, ['a', null, 'b', 'c'], 4), { + layout: 4, assignments: ['a', null, 'b', 'c'] + }); +}); test('failure advice never recommends retry for ambiguous or permission failures', () => { assert.equal(helpers.adviceKey('PERMISSION_DENIED'), 'permission'); assert.equal(helpers.adviceKey('OUTCOME_UNKNOWN'), 'unknown');