diff --git a/frontend/index.html b/frontend/index.html index fa995ab..40f9dfe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,6 +16,7 @@ + @@ -168,6 +169,7 @@ + @@ -175,6 +177,7 @@ + diff --git a/frontend/src/css/modals.css b/frontend/src/css/modals.css index 23df078..efbf8b3 100644 --- a/frontend/src/css/modals.css +++ b/frontend/src/css/modals.css @@ -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); } diff --git a/frontend/src/css/splitpane.css b/frontend/src/css/splitpane.css new file mode 100644 index 0000000..f63f970 --- /dev/null +++ b/frontend/src/css/splitpane.css @@ -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; +} diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index e862cd5..c1253b7 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -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 ───────────────────────────────────────── diff --git a/frontend/src/js/secrets.js b/frontend/src/js/secrets.js index 56c65ac..d6ee68a 100644 --- a/frontend/src/js/secrets.js +++ b/frontend/src/js/secrets.js @@ -134,6 +134,10 @@ class SecretPrompt { `; if (isNew) { formHtml += ` +
+
+ +
{ + 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; diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index 1e126da..66a7b6f 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -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', ]; diff --git a/frontend/src/js/splitpane.js b/frontend/src/js/splitpane.js new file mode 100644 index 0000000..00cfe45 --- /dev/null +++ b/frontend/src/js/splitpane.js @@ -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} + */ + 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 = ''; + + 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; + +})(); diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 6630eec..f83ad9a 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -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); } } diff --git a/frontend/src/js/themes.js b/frontend/src/js/themes.js new file mode 100644 index 0000000..5ede03f --- /dev/null +++ b/frontend/src/js/themes.js @@ -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; + +})(); diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 681ccb2..2b97936 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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"]; diff --git a/internal/models/defaults.go b/internal/models/defaults.go index 72263b1..3ad7b17 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -39,6 +39,7 @@ const ( func DefaultAppPreferences() AppPreferences { return AppPreferences{ Theme: "dark", + TerminalTheme: "charcoal", DefaultProtocol: string(ProtocolSSH), ConfirmOnClose: true, ConfirmOnDisconnect: true, diff --git a/internal/models/models.go b/internal/models/models.go index 0021a3b..81b05f1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"`