feat: add New Session settings tab with template textarea

This commit is contained in:
Brian Krabach
2026-03-30 01:03:54 -07:00
parent 2e86692c3c
commit 5f81b87348
7 changed files with 890 additions and 102 deletions
+24
View File
@@ -141,6 +141,7 @@ const DISPLAY_DEFAULTS = {
bellSound: false,
notificationPermission: 'default',
};
const NEW_SESSION_DEFAULT_TEMPLATE = 'tmux new-session -d -s {name}';
// ─── DOM helpers ──────────────────────────────────────────────────────────────
function $(id) {
@@ -1126,6 +1127,12 @@ function openSettings() {
if (autoOpenEl) {
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
}
// New Session tab - populate template textarea
const templateEl = $('setting-template');
if (templateEl) {
templateEl.value = (ss && ss.new_session_template) || NEW_SESSION_DEFAULT_TEMPLATE;
}
});
}
@@ -1390,6 +1397,23 @@ function bindStaticEventListeners() {
console.error('Notification.requestPermission() failed:', err);
});
});
// New Session tab — template textarea with 500ms debounce
var _templateDebounceTimer;
on($('setting-template'), 'input', function() {
clearTimeout(_templateDebounceTimer);
var val = this.value;
_templateDebounceTimer = setTimeout(function() {
patchServerSetting('new_session_template', val);
}, 500);
});
// New Session tab — reset button restores default template
on($('setting-template-reset'), 'click', function() {
var el = $('setting-template');
if (el) el.value = NEW_SESSION_DEFAULT_TEMPLATE;
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
});
}
// ─── Test-only helpers ────────────────────────────────────────────────────────
+8 -1
View File
@@ -152,7 +152,14 @@
</div>
</div>
</div>
<div class="settings-panel hidden" data-tab="new-session"></div>
<div class="settings-panel hidden" data-tab="new-session">
<div class="settings-field settings-field--column">
<label class="settings-label" for="setting-template">Command template</label>
<textarea id="setting-template" class="settings-textarea" rows="3" placeholder="tmux new-session -d -s {name}"></textarea>
<span class="settings-helper">{name} is replaced with the session name</span>
<button id="setting-template-reset" class="settings-action-btn">Reset to default</button>
</div>
</div>
</div>
</div>
</dialog>
+28
View File
@@ -1091,6 +1091,34 @@ body {
cursor: pointer;
}
/* ============================================================
New Session tab controls
============================================================ */
.settings-textarea {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
padding: 10px;
resize: vertical;
min-height: 60px;
outline: none;
}
.settings-textarea:focus {
border-color: var(--accent);
}
.settings-helper {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
/* ============================================================
Notifications tab controls
============================================================ */
+255
View File
@@ -2127,4 +2127,259 @@ test('ANSI_COLORS uses xterm.js default GTK/Tango palette', () => {
assert.strictEqual(app.ansi256Color(14), '#34e2e2', 'color 14 must be xterm.js default: #34e2e2');
});
// --- New Session tab ---
test('HTML index.html has setting-template textarea with correct attributes', () => {
const source = fs.readFileSync(
new URL('../index.html', import.meta.url), 'utf8'
);
assert.ok(source.includes('id="setting-template"'), 'must have setting-template textarea');
assert.ok(source.includes('settings-textarea'), 'must have settings-textarea class');
assert.ok(source.includes('rows="3"'), 'must have rows=3');
assert.ok(source.includes('placeholder="tmux new-session -d -s {name}"'), 'must have correct placeholder');
});
test('HTML index.html has setting-template-reset button', () => {
const source = fs.readFileSync(
new URL('../index.html', import.meta.url), 'utf8'
);
assert.ok(source.includes('id="setting-template-reset"'), 'must have setting-template-reset button');
assert.ok(source.includes('settings-action-btn'), 'must use settings-action-btn class on reset button');
});
test('HTML index.html has settings-helper text for template', () => {
const source = fs.readFileSync(
new URL('../index.html', import.meta.url), 'utf8'
);
assert.ok(source.includes('settings-helper'), 'must have settings-helper class');
assert.ok(source.includes('{name} is replaced with the session name'), 'must have helper text');
});
test('CSS style.css has .settings-textarea class', () => {
const source = fs.readFileSync(
new URL('../style.css', import.meta.url), 'utf8'
);
assert.ok(source.includes('.settings-textarea'), 'must have .settings-textarea CSS class');
});
test('CSS style.css has .settings-helper class', () => {
const source = fs.readFileSync(
new URL('../style.css', import.meta.url), 'utf8'
);
assert.ok(source.includes('.settings-helper'), 'must have .settings-helper CSS class');
});
test('openSettings populates setting-template textarea from server settings', async () => {
// Reset _currentSessions to empty to avoid createTextNode calls in openSettings callback
app._setCurrentSessions([]);
const elements = {};
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (url === '/api/settings') {
return {
ok: true,
json: async () => ({ new_session_template: 'my-custom-template' }),
};
}
return { ok: true, json: async () => ({}) };
};
const origGetById = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (!elements[id]) {
elements[id] = {
value: '',
checked: false,
innerHTML: '',
disabled: false,
appendChild: () => {},
querySelectorAll: () => [],
classList: { add: () => {}, remove: () => {} },
showModal: () => {},
close: () => {},
addEventListener: () => {},
};
}
return elements[id];
};
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.querySelectorAll = () => [];
const origCreateTextNode = globalThis.document.createTextNode;
globalThis.document.createTextNode = (text) => ({ nodeType: 3, textContent: text });
app.openSettings();
// Flush microtask queue so all Promises in loadServerSettings chain resolve
// before yielding to macrotask queue (where other tests could start).
// Each await Promise.resolve() flushes one microtask hop; 10 covers nested chains.
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.strictEqual(
elements['setting-template'] && elements['setting-template'].value,
'my-custom-template',
'textarea should be populated with new_session_template from server settings',
);
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
globalThis.document.createTextNode = origCreateTextNode;
});
test('openSettings uses default template when new_session_template not in server settings', async () => {
// Reset _currentSessions to empty to avoid createTextNode calls in openSettings callback
app._setCurrentSessions([]);
const elements = {};
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (url === '/api/settings') {
return {
ok: true,
json: async () => ({}),
};
}
return { ok: true, json: async () => ({}) };
};
const origGetById = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (!elements[id]) {
elements[id] = {
value: '',
checked: false,
innerHTML: '',
disabled: false,
appendChild: () => {},
querySelectorAll: () => [],
classList: { add: () => {}, remove: () => {} },
showModal: () => {},
close: () => {},
addEventListener: () => {},
};
}
return elements[id];
};
const origQSA = globalThis.document.querySelectorAll;
globalThis.document.querySelectorAll = () => [];
const origCreateTextNode = globalThis.document.createTextNode;
globalThis.document.createTextNode = (text) => ({ nodeType: 3, textContent: text });
app.openSettings();
// Flush microtask queue so all Promises in loadServerSettings chain resolve
// before yielding to macrotask queue (where other tests could start).
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.strictEqual(
elements['setting-template'] && elements['setting-template'].value,
'tmux new-session -d -s {name}',
'textarea should use default when new_session_template not set',
);
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelectorAll = origQSA;
globalThis.document.createTextNode = origCreateTextNode;
});
test('bindStaticEventListeners binds input on setting-template', () => {
const eventsBound = {};
const origGetById = globalThis.document.getElementById;
const origDocAddListener = globalThis.document.addEventListener;
globalThis.document.getElementById = (id) => {
const el = {
_events: {},
addEventListener: (ev, fn) => { el._events[ev] = fn; },
value: '',
querySelectorAll: () => [],
};
eventsBound[id] = el;
return el;
};
globalThis.document.addEventListener = () => {};
app.bindStaticEventListeners();
assert.ok(
eventsBound['setting-template'] && 'input' in eventsBound['setting-template']._events,
'#setting-template should have an input listener',
);
globalThis.document.getElementById = origGetById;
globalThis.document.addEventListener = origDocAddListener;
});
test('bindStaticEventListeners binds click on setting-template-reset', () => {
const eventsBound = {};
const origGetById = globalThis.document.getElementById;
const origDocAddListener = globalThis.document.addEventListener;
globalThis.document.getElementById = (id) => {
const el = {
_events: {},
addEventListener: (ev, fn) => { el._events[ev] = fn; },
value: '',
querySelectorAll: () => [],
};
eventsBound[id] = el;
return el;
};
globalThis.document.addEventListener = () => {};
app.bindStaticEventListeners();
assert.ok(
eventsBound['setting-template-reset'] && 'click' in eventsBound['setting-template-reset']._events,
'#setting-template-reset should have a click listener',
);
globalThis.document.getElementById = origGetById;
globalThis.document.addEventListener = origDocAddListener;
});
test('setting-template-reset click resets textarea to default value', () => {
const elements = {};
const origFetch = globalThis.fetch;
globalThis.fetch = async () => ({ ok: true, json: async () => ({}) });
const origGetById = globalThis.document.getElementById;
const origDocAddListener = globalThis.document.addEventListener;
globalThis.document.getElementById = (id) => {
if (!elements[id]) {
elements[id] = {
_events: {},
addEventListener: (ev, fn) => { elements[id]._events[ev] = fn; },
value: 'custom-value',
querySelectorAll: () => [],
};
}
return elements[id];
};
globalThis.document.addEventListener = () => {};
app.bindStaticEventListeners();
// Simulate reset button click
if (elements['setting-template-reset']) {
elements['setting-template-reset']._events.click();
}
assert.strictEqual(
elements['setting-template'] && elements['setting-template'].value,
'tmux new-session -d -s {name}',
'textarea should be reset to default value on reset button click',
);
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.addEventListener = origDocAddListener;
});
test('app.js source uses 500ms debounce for template input and references new_session_template', () => {
const source = fs.readFileSync(
new URL('../app.js', import.meta.url), 'utf8'
);
assert.ok(source.includes('500'), 'must have 500ms debounce timeout');
assert.ok(source.includes('new_session_template'), 'must reference new_session_template setting key');
});