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:
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user