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
2 changes: 1 addition & 1 deletion docs/wiki/Terminal-and-Persistent-tmux-Sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions static/css/terminal-windows.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions static/js/customer-workflows-i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "检查计划",
Expand Down
107 changes: 95 additions & 12 deletions static/js/customer-workflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)};
}
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
<p class="workspace-layout-hint" data-i18n="windows.snapHint">Drag a title bar to an edge to snap; to a corner for a quarter.</p>
</div>
<div class="workspace-layout-presets">
<div id="workspacePresetOptions" class="workspace-preset-options" role="group" aria-label="Workspace presets" data-i18n-aria-label="customer.presetsTitle"></div>
<button id="workspacePresetsBtn" data-workspace-presets class="tab-row-icon-btn" type="button"><span class="material-icons" aria-hidden="true">view_quilt</span><span data-i18n="customer.presetsTitle">Workspace presets</span></button>
</div>
</div>
Expand Down
Loading
Loading