Phase 2: Split pane terminals, custom themes, and password strength meter

Split Pane Layout:
- Add SplitPaneManager with recursive tree-based pane splitting
- Support horizontal (Cmd+Shift+E) and vertical (Cmd+Shift+D) splits
- Draggable resize dividers with 100px minimum pane size
- Hover-visible close button to unsplit panes
- Session picker in new panes for connecting separate terminals
- Automatic xterm refit on all pane resize operations

Custom Terminal Themes:
- Add themes.js with 8 preset colour schemes: Charcoal (default),
  Solarized Dark, Monokai, Nord, Dracula, One Dark, Gruvbox Dark,
  Tokyo Night
- Add TerminalTheme preference to AppPreferences model
- Add colour scheme selector to Settings → Terminal tab
- Live-apply theme changes to all active terminals on save
- TerminalManager now uses themed colours instead of hardcoded values

Master Password Strength Meter:
- Add visual strength bar to vault creation dialog
- Entropy estimation based on length, character diversity, uniqueness
- Four levels: Weak (red), Fair (amber), Good (green), Strong (green)
- Animated transitions on strength changes
- Charcoal-themed CSS matching modal design
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 5a998aefb9
commit da6320c04b
12 changed files with 1257 additions and 28 deletions
+3
View File
@@ -16,6 +16,7 @@
<link rel="stylesheet" href="/src/css/sidebar.css">
<link rel="stylesheet" href="/src/css/tabs.css">
<link rel="stylesheet" href="/src/css/terminal.css">
<link rel="stylesheet" href="/src/css/splitpane.css">
<link rel="stylesheet" href="/src/css/forms.css">
<link rel="stylesheet" href="/src/css/modals.css">
<link rel="stylesheet" href="/src/css/github.css">
@@ -168,6 +169,7 @@
<script src="/src/js/notifications.js"></script>
<script src="/src/js/sidebar.js"></script>
<script src="/src/js/tabs.js"></script>
<script src="/src/js/themes.js"></script>
<script src="/src/js/terminal.js"></script>
<script src="/src/js/profiles.js"></script>
<script src="/src/js/raw.js"></script>
@@ -175,6 +177,7 @@
<script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
<script src="/src/js/splitpane.js"></script>
<script src="/src/js/app.js"></script>
</body>
</html>
+42
View File
@@ -665,3 +665,45 @@
padding: 1px 4px;
border-radius: 2px;
}
/* -----------------------------------------------------------------------
Password strength meter
----------------------------------------------------------------------- */
.password-strength-meter {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.strength-bar {
flex: 1;
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
width: 0;
border-radius: 2px;
transition: width 0.3s ease, background-color 0.3s ease;
}
.strength-fill.strength-weak { background: var(--error); }
.strength-fill.strength-fair { background: var(--warning); }
.strength-fill.strength-good { background: #66bb6a; }
.strength-fill.strength-strong { background: var(--success); }
.strength-label {
font-size: var(--text-xs);
font-weight: 500;
min-width: 48px;
text-align: right;
}
.strength-label.strength-weak { color: var(--error); }
.strength-label.strength-fair { color: var(--warning); }
.strength-label.strength-good { color: #66bb6a; }
.strength-label.strength-strong { color: var(--success); }
+223
View File
@@ -0,0 +1,223 @@
/* ==========================================================================
Nexus Terminal — Split Pane Layout
Draggable split panes within session panels for multi-terminal views.
========================================================================== */
/* -----------------------------------------------------------------------
Split container — replaces direct terminal-container when split
----------------------------------------------------------------------- */
.split-container {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.split-container.split-horizontal {
flex-direction: column;
}
.split-container.split-vertical {
flex-direction: row;
}
/* -----------------------------------------------------------------------
Individual pane inside a split
----------------------------------------------------------------------- */
.split-pane {
position: relative;
overflow: hidden;
min-width: 100px;
min-height: 100px;
display: flex;
flex-direction: column;
}
/* Active pane indicator (subtle border glow) */
.split-pane.is-focused {
box-shadow: inset 0 0 0 1px var(--accent-muted);
}
/* -----------------------------------------------------------------------
Pane close button — only visible on hover
----------------------------------------------------------------------- */
.split-pane-close {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
color: var(--text-tertiary);
font-size: 11px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
z-index: 5;
}
.split-pane:hover > .split-pane-close {
opacity: 1;
}
.split-pane-close:hover {
background: var(--error);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Draggable divider between panes
----------------------------------------------------------------------- */
.split-divider {
flex-shrink: 0;
background: var(--border-subtle);
position: relative;
z-index: 2;
transition: background var(--transition-fast);
}
.split-divider:hover,
.split-divider.is-dragging {
background: var(--accent);
}
/* Vertical split → side-by-side panes → vertical divider bar */
.split-container.split-vertical > .split-divider {
width: 4px;
cursor: col-resize;
}
/* Horizontal split → stacked panes → horizontal divider bar */
.split-container.split-horizontal > .split-divider {
height: 4px;
cursor: row-resize;
}
/* Invisible larger hit-area for easier grabbing */
.split-divider::after {
content: '';
position: absolute;
}
.split-container.split-vertical > .split-divider::after {
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
.split-container.split-horizontal > .split-divider::after {
left: 0;
right: 0;
top: -4px;
bottom: -4px;
}
/* -----------------------------------------------------------------------
Empty pane placeholder — shown when a new pane needs a session
----------------------------------------------------------------------- */
.split-pane-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
gap: var(--space-3);
color: var(--text-tertiary);
font-size: var(--text-sm);
background: var(--bg-base);
padding: var(--space-4);
}
.split-pane-placeholder-icon {
opacity: 0.4;
}
.split-pane-placeholder-text {
text-align: center;
max-width: 200px;
line-height: var(--leading-normal);
}
/* Profile picker list in the placeholder */
.split-pane-profile-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 200px;
overflow-y: auto;
width: 100%;
max-width: 220px;
margin-top: var(--space-2);
}
.split-pane-profile-list::-webkit-scrollbar {
width: 4px;
}
.split-pane-profile-list::-webkit-scrollbar-track {
background: transparent;
}
.split-pane-profile-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
.split-pane-profile-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: var(--text-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.split-pane-profile-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-muted);
}
.split-pane-profile-btn .protocol-badge {
font-size: 9px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
opacity: 0.7;
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
Prevent text selection while dragging dividers
----------------------------------------------------------------------- */
body.split-resizing {
cursor: col-resize !important;
user-select: none !important;
-webkit-user-select: none !important;
}
body.split-resizing-row {
cursor: row-resize !important;
user-select: none !important;
-webkit-user-select: none !important;
}
/* Disable pointer events on iframes / xterm canvases during drag */
body.split-resizing .xterm,
body.split-resizing-row .xterm {
pointer-events: none;
}
+35 -2
View File
@@ -4,7 +4,7 @@
* Initialises the application shell, loads profiles, and sets up event listeners.
*/
const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, stateLabel } = window.NexusTerminal;
const { State, Sidebar, TabManager, TerminalManager, ProfileEditor, RawViewer, Notifications, Settings, CommandPalette, SecretPrompt, SplitPaneManager, stateLabel } = window.NexusTerminal;
class App {
constructor() {
@@ -18,6 +18,7 @@ class App {
this.settings = new Settings(this);
this.commandPalette = new CommandPalette(this);
this.secretPrompt = new SecretPrompt(this);
this.splitPanes = new SplitPaneManager(this);
}
/**
@@ -321,6 +322,20 @@ class App {
if (e.key === 'Escape') {
this.commandPalette.close();
}
// Ctrl/Cmd+Shift+D — split pane vertically (left/right)
if (mod && e.shiftKey && e.key === 'D') {
e.preventDefault();
this.splitPane('vertical');
return;
}
// Ctrl/Cmd+Shift+E — split pane horizontally (top/bottom)
if (mod && e.shiftKey && e.key === 'E') {
e.preventDefault();
this.splitPane('horizontal');
return;
}
});
}
@@ -345,7 +360,25 @@ class App {
// Window resize → re-fit active terminal (debounced)
const { debounce } = window.NexusTerminal;
window.addEventListener('resize', debounce(() => this.terminals.fitActive(), 100));
window.addEventListener('resize', debounce(() => {
this.terminals.fitActive();
this.splitPanes.refitAll();
}, 100));
}
// ── Split pane ─────────────────────────────────────────────────
/**
* Splits the active session panel in the given direction.
* @param {'horizontal'|'vertical'} direction
*/
splitPane(direction) {
if (!this.state.activeSessionId) return;
if (direction === 'horizontal') {
this.splitPanes.splitHorizontal();
} else {
this.splitPanes.splitVertical();
}
}
// ── Connection lifecycle ─────────────────────────────────────────
+53
View File
@@ -134,6 +134,10 @@ class SecretPrompt {
`;
if (isNew) {
formHtml += `
<div class="password-strength-meter">
<div class="strength-bar"><div class="strength-fill"></div></div>
<span class="strength-label"></span>
</div>
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input master-confirm"
@@ -166,6 +170,19 @@ class SecretPrompt {
const errorEl = modal.querySelector('.secret-error');
passInput.focus();
// Wire up the password strength meter for new vault creation.
if (isNew) {
const strengthFill = modal.querySelector('.strength-fill');
const strengthLabel = modal.querySelector('.strength-label');
passInput.addEventListener('input', () => {
const result = _measureStrength(passInput.value);
strengthFill.style.width = result.percent + '%';
strengthFill.className = 'strength-fill strength-' + result.level;
strengthLabel.textContent = result.label;
strengthLabel.className = 'strength-label strength-' + result.level;
});
}
const handleSubmit = () => {
const pw = passInput.value;
if (!pw) {
@@ -310,6 +327,42 @@ class SecretPrompt {
}
}
/**
* Estimates password strength based on length and character diversity.
* Returns an object with { percent, level, label } for the strength meter.
* @param {string} pw
* @returns {{ percent: number, level: string, label: string }}
* @private
*/
function _measureStrength(pw) {
if (!pw) return { percent: 0, level: 'none', label: '' };
let score = 0;
const len = pw.length;
// Length contribution (0–40 points)
score += Math.min(len * 3, 40);
// Character-class diversity (0–40 points)
const hasLower = /[a-z]/.test(pw);
const hasUpper = /[A-Z]/.test(pw);
const hasDigit = /[0-9]/.test(pw);
const hasSymbol = /[^a-zA-Z0-9]/.test(pw);
const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
score += classes * 10;
// Unique-character ratio bonus (0–20 points)
const unique = new Set(pw).size;
score += Math.min(Math.floor((unique / Math.max(len, 1)) * 20), 20);
score = Math.min(score, 100);
if (score < 25) return { percent: score, level: 'weak', label: 'Weak' };
if (score < 50) return { percent: score, level: 'fair', label: 'Fair' };
if (score < 75) return { percent: score, level: 'good', label: 'Good' };
return { percent: score, level: 'strong', label: 'Strong' };
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SecretPrompt = SecretPrompt;
+14 -1
View File
@@ -73,6 +73,12 @@ class Settings {
try {
await window.go.api.Service.SavePreferences(this._prefs);
this.app.state.setPreferences(this._prefs);
// Apply terminal colour theme change immediately.
if (this.app.terminals && this._prefs.terminalTheme) {
this.app.terminals.applyTheme(this._prefs.terminalTheme);
}
this.app.notifications.success('Settings saved');
this.close();
} catch (err) {
@@ -205,6 +211,13 @@ class Settings {
*/
_renderTerminalTab(body) {
const p = this._prefs;
// Terminal colour theme selector
const themes = window.NexusTerminal.listTerminalThemes();
body.appendChild(this._settingField('select', 'terminalTheme', 'Colour scheme', p.terminalTheme || 'charcoal', {
options: themes,
}));
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}));
@@ -455,7 +468,7 @@ class Settings {
};
const fields = [
'theme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
'theme', 'terminalTheme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
'defaultScrollback', 'defaultCursorStyle',
];
+600
View File
@@ -0,0 +1,600 @@
(function() {
/**
* Nexus Terminal — Split Pane Manager.
* Enables splitting session panels into multiple panes, each hosting
* its own terminal session. Supports horizontal (top/bottom) and
* vertical (left/right) splits with draggable resize dividers.
*/
var MIN_PANE_SIZE = 100; // px
/**
* Unique pane id counter.
* @type {number}
*/
var _paneIdCounter = 0;
function nextPaneId() {
return 'pane-' + (++_paneIdCounter);
}
class SplitPaneManager {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → split tree root (or null if unsplit).
* The tree is a simple object:
* Leaf: { type: 'leaf', paneId, el, sessionId }
* Branch: { type: 'branch', direction, first, second, el, divider, ratio }
* @type {Map<string, Object|null>}
*/
this.trees = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Splits the active session panel vertically (left | right).
*/
splitVertical() {
this._splitActive('vertical');
}
/**
* Splits the active session panel horizontally (top / bottom).
*/
splitHorizontal() {
this._splitActive('horizontal');
}
/**
* Removes all split state for a session (used on tab close).
* @param {string} sessionId
*/
cleanup(sessionId) {
this.trees.delete(sessionId);
}
/**
* Re-fits all terminals visible in the active session's pane tree.
*/
refitAll() {
var activeId = this.app.state.activeSessionId;
if (!activeId) return;
var tree = this.trees.get(activeId);
if (tree) this._refitTree(tree);
}
// ── Core split logic ─────────────────────────────────────────────
/**
* Splits the active session panel.
* @param {'horizontal'|'vertical'} direction
* @private
*/
_splitActive(direction) {
var sessionId = this.app.state.activeSessionId;
if (!sessionId) return;
var panel = document.getElementById('panel-' + sessionId);
if (!panel) return;
var tree = this.trees.get(sessionId);
if (!tree) {
// First split — wrap existing content
tree = this._initTree(panel, sessionId);
}
// Find the focused leaf (or the first leaf)
var leaf = this._findFocusedLeaf(tree) || this._firstLeaf(tree);
if (!leaf) return;
this._splitLeaf(leaf, direction, sessionId);
}
/**
* Creates the initial tree structure from an unsplit panel.
* Wraps the existing terminal-container in a split-pane leaf.
* @param {HTMLElement} panel
* @param {string} sessionId
* @returns {Object} root leaf node
* @private
*/
_initTree(panel, sessionId) {
var termContainer = panel.querySelector('.terminal-container');
// For non-terminal panels (raw, github), wrap whatever content exists
var contentChildren = Array.from(panel.children);
var paneId = nextPaneId();
var paneEl = document.createElement('div');
paneEl.className = 'split-pane';
paneEl.dataset.paneId = paneId;
paneEl.style.flex = '1';
// Move existing content into the pane
contentChildren.forEach(function(child) {
paneEl.appendChild(child);
});
// Reset terminal-container from absolute to relative positioning inside a pane
if (termContainer) {
termContainer.style.position = 'relative';
termContainer.style.inset = 'auto';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
}
panel.appendChild(paneEl);
var leaf = {
type: 'leaf',
paneId: paneId,
el: paneEl,
sessionId: sessionId,
parent: null
};
paneEl._splitNode = leaf;
this.trees.set(sessionId, leaf);
// Add focus tracking
this._addFocusTracking(paneEl);
return leaf;
}
/**
* Splits a leaf node into two panes.
* @param {Object} leaf - The leaf node to split.
* @param {'horizontal'|'vertical'} direction
* @param {string} ownerSessionId - The session that owns this panel.
* @private
*/
_splitLeaf(leaf, direction, ownerSessionId) {
var parentBranch = leaf.parent;
var panel = this._findPanel(ownerSessionId);
if (!panel) return;
// Create a container for the two panes
var containerEl = document.createElement('div');
containerEl.className = 'split-container split-' + direction;
containerEl.style.flex = leaf.el.style.flex || '1';
// The existing leaf becomes the first child
leaf.el.style.flex = '1';
// Create divider
var divider = document.createElement('div');
divider.className = 'split-divider';
// Create new empty pane
var newPaneId = nextPaneId();
var newPaneEl = document.createElement('div');
newPaneEl.className = 'split-pane';
newPaneEl.dataset.paneId = newPaneId;
newPaneEl.style.flex = '1';
// Add a placeholder with session picker to the new pane
this._renderPlaceholder(newPaneEl, ownerSessionId);
// Add close buttons to both panes
this._addCloseButton(leaf.el, ownerSessionId);
this._addCloseButton(newPaneEl, ownerSessionId);
// Add focus tracking
this._addFocusTracking(newPaneEl);
// Build the new branch node
var newLeaf = {
type: 'leaf',
paneId: newPaneId,
el: newPaneEl,
sessionId: null,
parent: null
};
newPaneEl._splitNode = newLeaf;
var branch = {
type: 'branch',
direction: direction,
first: leaf,
second: newLeaf,
el: containerEl,
divider: divider,
ratio: 0.5,
parent: parentBranch
};
leaf.parent = branch;
newLeaf.parent = branch;
containerEl._splitNode = branch;
// Replace the leaf element in the DOM
var leafParent = leaf.el.parentNode;
leafParent.insertBefore(containerEl, leaf.el);
containerEl.appendChild(leaf.el);
containerEl.appendChild(divider);
containerEl.appendChild(newPaneEl);
// Update the tree structure
if (parentBranch) {
if (parentBranch.first === leaf) {
parentBranch.first = branch;
} else {
parentBranch.second = branch;
}
} else {
// This was the root leaf — the branch becomes the new root
this.trees.set(ownerSessionId, branch);
}
// Set up divider dragging
this._setupDividerDrag(divider, branch);
// Refit terminals after layout change
var self = this;
requestAnimationFrame(function() {
self._refitTree(self.trees.get(ownerSessionId));
});
}
// ── Pane closing ─────────────────────────────────────────────────
/**
* Closes a pane and unsplits, keeping the sibling.
* @param {string} paneId
* @param {string} ownerSessionId
* @private
*/
_closePane(paneId, ownerSessionId) {
var root = this.trees.get(ownerSessionId);
if (!root) return;
var leaf = this._findLeafById(root, paneId);
if (!leaf || !leaf.parent) {
// Only one pane left — can't close it
return;
}
var branch = leaf.parent;
var sibling = (branch.first === leaf) ? branch.second : branch.first;
// If the closing pane has a terminal session, destroy it (but not the owner session)
if (leaf.sessionId && leaf.sessionId !== ownerSessionId) {
this.app.terminals.destroyTerminal(leaf.sessionId);
}
// Replace the branch element with the sibling element in the DOM
sibling.el.style.flex = branch.el.style.flex || '1';
branch.el.parentNode.insertBefore(sibling.el, branch.el);
branch.el.remove();
// Remove close button from sibling if it's now the only pane
sibling.parent = branch.parent;
if (branch.parent) {
if (branch.parent.first === branch) {
branch.parent.first = sibling;
} else {
branch.parent.second = sibling;
}
} else {
// Sibling is now root
this.trees.set(ownerSessionId, sibling);
}
// Remove close button if we're back to a single leaf
var newRoot = this.trees.get(ownerSessionId);
if (newRoot && newRoot.type === 'leaf') {
var closeBtn = newRoot.el.querySelector(':scope > .split-pane-close');
if (closeBtn) closeBtn.remove();
}
// Refit terminals
var self = this;
requestAnimationFrame(function() {
self._refitTree(self.trees.get(ownerSessionId));
});
}
// ── Divider drag ─────────────────────────────────────────────────
/**
* Sets up mouse drag behaviour on a divider.
* @param {HTMLElement} divider
* @param {Object} branch
* @private
*/
_setupDividerDrag(divider, branch) {
var self = this;
divider.addEventListener('mousedown', function(e) {
e.preventDefault();
var isHorizontal = branch.direction === 'horizontal';
var bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
document.body.classList.add(bodyClass);
divider.classList.add('is-dragging');
var containerRect = branch.el.getBoundingClientRect();
var dividerSize = isHorizontal ? 4 : 4; // divider thickness
function onMouseMove(e) {
var totalSize, mousePos;
if (isHorizontal) {
totalSize = containerRect.height - dividerSize;
mousePos = e.clientY - containerRect.top;
} else {
totalSize = containerRect.width - dividerSize;
mousePos = e.clientX - containerRect.left;
}
var ratio = mousePos / (totalSize + dividerSize);
ratio = Math.max(MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
ratio = Math.min(1 - MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
branch.ratio = ratio;
branch.first.el.style.flex = ratio + '';
branch.second.el.style.flex = (1 - ratio) + '';
// Refit terminals in this branch
self._refitTree(branch);
}
function onMouseUp() {
document.body.classList.remove(bodyClass);
divider.classList.remove('is-dragging');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Final refit
self._refitTree(branch);
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
// ── Placeholder / session picker ─────────────────────────────────
/**
* Renders a placeholder with a list of available profiles to connect.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_renderPlaceholder(paneEl, ownerSessionId) {
var placeholder = document.createElement('div');
placeholder.className = 'split-pane-placeholder';
var icon = document.createElement('div');
icon.className = 'split-pane-placeholder-icon';
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>';
var text = document.createElement('div');
text.className = 'split-pane-placeholder-text';
text.textContent = 'Select a connection for this pane';
placeholder.appendChild(icon);
placeholder.appendChild(text);
// Build profile list
var profiles = this.app.state.profiles || [];
if (profiles.length > 0) {
var list = document.createElement('div');
list.className = 'split-pane-profile-list';
var self = this;
profiles.forEach(function(profile) {
var btn = document.createElement('button');
btn.className = 'split-pane-profile-btn';
var badge = document.createElement('span');
badge.className = 'protocol-badge';
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
badge.style.color = self._protocolColor(profile.protocol);
var label = document.createElement('span');
label.textContent = profile.displayName || profile.host || profile.id;
btn.appendChild(badge);
btn.appendChild(label);
btn.addEventListener('click', function() {
self._connectInPane(paneEl, profile, ownerSessionId);
});
list.appendChild(btn);
});
placeholder.appendChild(list);
}
paneEl.appendChild(placeholder);
}
/**
* Connects a profile in a specific pane.
* @param {HTMLElement} paneEl
* @param {Object} profile
* @param {string} ownerSessionId
* @private
*/
_connectInPane(paneEl, profile, ownerSessionId) {
var self = this;
var node = paneEl._splitNode;
// Remove placeholder
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
// Connect via backend
window.go.api.Service.ConnectSession(profile.id).then(function(sessionInfo) {
self.app.state.addSession(sessionInfo);
// Create a terminal container inside this pane
var termContainer = document.createElement('div');
termContainer.className = 'terminal-container';
termContainer.id = 'split-term-' + sessionInfo.id;
termContainer.style.position = 'relative';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
paneEl.appendChild(termContainer);
// Track which session is in this pane
if (node) {
node.sessionId = sessionInfo.id;
}
// Create terminal
self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile);
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
}).catch(function(err) {
self.app.notifications.error('Connection failed: ' + err);
// Re-render placeholder on failure
self._renderPlaceholder(paneEl, ownerSessionId);
});
}
// ── Tree traversal helpers ───────────────────────────────────────
/**
* Finds a leaf by paneId in the tree.
* @param {Object} node
* @param {string} paneId
* @returns {Object|null}
* @private
*/
_findLeafById(node, paneId) {
if (!node) return null;
if (node.type === 'leaf') {
return node.paneId === paneId ? node : null;
}
return this._findLeafById(node.first, paneId) || this._findLeafById(node.second, paneId);
}
/**
* Returns the first leaf node in the tree.
* @param {Object} node
* @returns {Object|null}
* @private
*/
_firstLeaf(node) {
if (!node) return null;
if (node.type === 'leaf') return node;
return this._firstLeaf(node.first);
}
/**
* Returns the focused leaf node (one whose pane has .is-focused).
* @param {Object} node
* @returns {Object|null}
* @private
*/
_findFocusedLeaf(node) {
if (!node) return null;
if (node.type === 'leaf') {
return node.el.classList.contains('is-focused') ? node : null;
}
return this._findFocusedLeaf(node.first) || this._findFocusedLeaf(node.second);
}
/**
* Recursively refits all terminals in a tree node.
* @param {Object} node
* @private
*/
_refitTree(node) {
if (!node) return;
if (node.type === 'leaf') {
if (node.sessionId) {
this.app.terminals.fitSession(node.sessionId);
}
return;
}
this._refitTree(node.first);
this._refitTree(node.second);
}
/**
* Finds the panel element for a session.
* @param {string} sessionId
* @returns {HTMLElement|null}
* @private
*/
_findPanel(sessionId) {
return document.getElementById('panel-' + sessionId);
}
// ── UI helpers ───────────────────────────────────────────────────
/**
* Adds a close button to a pane element.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_addCloseButton(paneEl, ownerSessionId) {
// Don't add if already present
if (paneEl.querySelector(':scope > .split-pane-close')) return;
var btn = document.createElement('button');
btn.className = 'split-pane-close';
btn.textContent = '✕';
btn.title = 'Close pane';
var self = this;
btn.addEventListener('click', function(e) {
e.stopPropagation();
self._closePane(paneEl.dataset.paneId, ownerSessionId);
});
paneEl.appendChild(btn);
}
/**
* Adds focus tracking to a pane — marks it as focused when clicked.
* @param {HTMLElement} paneEl
* @private
*/
_addFocusTracking(paneEl) {
paneEl.addEventListener('mousedown', function() {
// Remove focus from all sibling panes
var allPanes = paneEl.closest('.session-panel');
if (allPanes) {
allPanes.querySelectorAll('.split-pane.is-focused').forEach(function(el) {
el.classList.remove('is-focused');
});
}
paneEl.classList.add('is-focused');
});
}
/**
* Returns a colour for a protocol badge.
* @param {string} protocol
* @returns {string}
* @private
*/
_protocolColor(protocol) {
var colors = {
ssh: '#4ecdc4',
telnet: '#ffa726',
raw: '#ab47bc',
github: '#7c8aff'
};
return colors[protocol] || '#4ecdc4';
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SplitPaneManager = SplitPaneManager;
})();
+15 -25
View File
@@ -33,8 +33,9 @@ class TerminalManager {
* @returns {Terminal} The xterm.js Terminal instance.
*/
createTerminal(sessionId, containerId, profile) {
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
const term = new Terminal({
theme: this._buildTheme(),
theme: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
fontSize: profile.terminalFontSize || 14,
scrollback: profile.terminalScrollback || 10000,
@@ -265,6 +266,17 @@ class TerminalManager {
if (entry) entry.term.focus();
}
/**
* Applies a named colour theme to all active terminal instances.
* @param {string} themeName
*/
applyTheme(themeName) {
const themeObj = window.NexusTerminal.getTerminalTheme(themeName);
for (const [, entry] of this.terminals) {
entry.term.options.theme = themeObj;
}
}
// ── Private helpers ──────────────────────────────────────────────
/**
@@ -273,30 +285,8 @@ class TerminalManager {
* @private
*/
_buildTheme() {
return {
background: '#1c1c1c',
foreground: '#e4e4e4',
cursor: '#4ecdc4',
cursorAccent: '#1c1c1c',
selectionBackground: 'rgba(78, 205, 196, 0.3)',
selectionForeground: '#ffffff',
black: '#1c1c1c',
red: '#ef5350',
green: '#66bb6a',
yellow: '#ffa726',
blue: '#42a5f5',
magenta: '#ab47bc',
cyan: '#4ecdc4',
white: '#e4e4e4',
brightBlack: '#555555',
brightRed: '#ff7043',
brightGreen: '#81c784',
brightYellow: '#ffcc02',
brightBlue: '#64b5f6',
brightMagenta: '#ce93d8',
brightCyan: '#80deea',
brightWhite: '#ffffff',
};
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
return window.NexusTerminal.getTerminalTheme(themeName);
}
}
+264
View File
@@ -0,0 +1,264 @@
(function() {
/**
* Nexus Terminal — terminal colour themes.
* Provides a collection of preset xterm.js colour schemes and a manager
* for applying them to terminal instances.
*/
/**
* Preset terminal colour themes.
* Each entry contains the full xterm.js theme object.
*/
const THEMES = {
charcoal: {
label: 'Charcoal (Default)',
theme: {
background: '#1c1c1c',
foreground: '#e4e4e4',
cursor: '#4ecdc4',
cursorAccent: '#1c1c1c',
selectionBackground: 'rgba(78, 205, 196, 0.3)',
selectionForeground: '#ffffff',
black: '#1c1c1c',
red: '#ef5350',
green: '#66bb6a',
yellow: '#ffa726',
blue: '#42a5f5',
magenta: '#ab47bc',
cyan: '#4ecdc4',
white: '#e4e4e4',
brightBlack: '#555555',
brightRed: '#ff7043',
brightGreen: '#81c784',
brightYellow: '#ffcc02',
brightBlue: '#64b5f6',
brightMagenta: '#ce93d8',
brightCyan: '#80deea',
brightWhite: '#ffffff',
},
},
'solarized-dark': {
label: 'Solarized Dark',
theme: {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: '#002b36',
selectionBackground: 'rgba(147, 161, 161, 0.3)',
selectionForeground: '#fdf6e3',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
},
},
monokai: {
label: 'Monokai',
theme: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
cursorAccent: '#272822',
selectionBackground: 'rgba(73, 72, 62, 0.6)',
selectionForeground: '#f8f8f2',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2',
brightBlack: '#75715e',
brightRed: '#f92672',
brightGreen: '#a6e22e',
brightYellow: '#f4bf75',
brightBlue: '#66d9ef',
brightMagenta: '#ae81ff',
brightCyan: '#a1efe4',
brightWhite: '#f9f8f5',
},
},
nord: {
label: 'Nord',
theme: {
background: '#2e3440',
foreground: '#d8dee9',
cursor: '#d8dee9',
cursorAccent: '#2e3440',
selectionBackground: 'rgba(136, 192, 208, 0.3)',
selectionForeground: '#eceff4',
black: '#3b4252',
red: '#bf616a',
green: '#a3be8c',
yellow: '#ebcb8b',
blue: '#81a1c1',
magenta: '#b48ead',
cyan: '#88c0d0',
white: '#e5e9f0',
brightBlack: '#4c566a',
brightRed: '#bf616a',
brightGreen: '#a3be8c',
brightYellow: '#ebcb8b',
brightBlue: '#81a1c1',
brightMagenta: '#b48ead',
brightCyan: '#8fbcbb',
brightWhite: '#eceff4',
},
},
dracula: {
label: 'Dracula',
theme: {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#282a36',
selectionBackground: 'rgba(68, 71, 90, 0.6)',
selectionForeground: '#f8f8f2',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
},
},
'one-dark': {
label: 'One Dark',
theme: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
cursorAccent: '#282c34',
selectionBackground: 'rgba(82, 139, 255, 0.3)',
selectionForeground: '#abb2bf',
black: '#282c34',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
},
'gruvbox-dark': {
label: 'Gruvbox Dark',
theme: {
background: '#282828',
foreground: '#ebdbb2',
cursor: '#ebdbb2',
cursorAccent: '#282828',
selectionBackground: 'rgba(214, 93, 14, 0.3)',
selectionForeground: '#ebdbb2',
black: '#282828',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
magenta: '#b16286',
cyan: '#689d6a',
white: '#a89984',
brightBlack: '#928374',
brightRed: '#fb4934',
brightGreen: '#b8bb26',
brightYellow: '#fabd2f',
brightBlue: '#83a598',
brightMagenta: '#d3869b',
brightCyan: '#8ec07c',
brightWhite: '#ebdbb2',
},
},
'tokyo-night': {
label: 'Tokyo Night',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: 'rgba(41, 46, 66, 0.9)',
selectionForeground: '#c0caf5',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
},
};
/**
* Returns the theme object for the given theme name.
* Falls back to 'charcoal' if not found.
* @param {string} name
* @returns {Object} xterm.js theme object
*/
function getTerminalTheme(name) {
const entry = THEMES[name || 'charcoal'];
return entry ? entry.theme : THEMES.charcoal.theme;
}
/**
* Returns all theme names and labels for building a selector.
* @returns {Array<{value: string, label: string}>}
*/
function listTerminalThemes() {
return Object.entries(THEMES).map(([value, entry]) => ({
value,
label: entry.label,
}));
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
})();
+6
View File
@@ -2,6 +2,7 @@ export namespace models {
export class AppPreferences {
theme: string;
terminalTheme: string;
defaultProtocol: string;
confirmOnClose: boolean;
confirmOnDisconnect: boolean;
@@ -19,6 +20,7 @@ export namespace models {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.theme = source["theme"];
this.terminalTheme = source["terminalTheme"];
this.defaultProtocol = source["defaultProtocol"];
this.confirmOnClose = source["confirmOnClose"];
this.confirmOnDisconnect = source["confirmOnDisconnect"];
@@ -307,6 +309,8 @@ export namespace models {
keepaliveInterval: number;
keepaliveCountMax: number;
compression: boolean;
agentAuth: boolean;
agentForwarding: boolean;
jumpHosts: JumpHost[];
portForwards: PortForward[];
startupCommand: string;
@@ -327,6 +331,8 @@ export namespace models {
this.keepaliveInterval = source["keepaliveInterval"];
this.keepaliveCountMax = source["keepaliveCountMax"];
this.compression = source["compression"];
this.agentAuth = source["agentAuth"];
this.agentForwarding = source["agentForwarding"];
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
this.portForwards = this.convertValues(source["portForwards"], PortForward);
this.startupCommand = source["startupCommand"];
+1
View File
@@ -39,6 +39,7 @@ const (
func DefaultAppPreferences() AppPreferences {
return AppPreferences{
Theme: "dark",
TerminalTheme: "charcoal",
DefaultProtocol: string(ProtocolSSH),
ConfirmOnClose: true,
ConfirmOnDisconnect: true,
+1
View File
@@ -230,6 +230,7 @@ type KnownHost struct {
// AppPreferences stores user-level application settings.
type AppPreferences struct {
Theme string `json:"theme"`
TerminalTheme string `json:"terminalTheme"` // colour scheme name (e.g. "charcoal", "solarized-dark")
DefaultProtocol string `json:"defaultProtocol"`
ConfirmOnClose bool `json:"confirmOnClose"`
ConfirmOnDisconnect bool `json:"confirmOnDisconnect"`