diff --git a/frontend/src/css/components.css b/frontend/src/css/components.css index 22ce283..22f368b 100644 --- a/frontend/src/css/components.css +++ b/frontend/src/css/components.css @@ -623,11 +623,24 @@ .notification-icon { flex-shrink: 0; - font-size: var(--text-lg); + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 11px; + font-weight: var(--weight-bold); line-height: 1; - margin-top: 1px; + color: var(--text-inverse); + background: var(--text-tertiary); } +.notification-success .notification-icon { background: var(--success); } +.notification-error .notification-icon { background: var(--error); } +.notification-warning .notification-icon { background: var(--warning); } +.notification-info .notification-icon { background: var(--info); } + .notification-body { flex: 1; min-width: 0; @@ -665,16 +678,9 @@ /* Colour variants (left border accent) */ .notification-success { border-left: 3px solid var(--success); } -.notification-success .notification-icon { color: var(--success); } - -.notification-error { border-left: 3px solid var(--error); } -.notification-error .notification-icon { color: var(--error); } - +.notification-error { border-left: 3px solid var(--error); } .notification-warning { border-left: 3px solid var(--warning); } -.notification-warning .notification-icon { color: var(--warning); } - -.notification-info { border-left: 3px solid var(--info); } -.notification-info .notification-icon { color: var(--info); } +.notification-info { border-left: 3px solid var(--info); } /* ======================================================================= @@ -790,7 +796,7 @@ flex-direction: column; align-items: center; justify-content: center; - height: 100%; + flex: 1; padding: var(--space-8); text-align: center; } diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index 12135a3..1a47997 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -70,6 +70,24 @@ position: relative; overflow: hidden; background: var(--bg-base); + display: flex; + flex-direction: column; +} + +/* Panel container — fills workspace, holds session panels */ +.panel-container { + flex: 1; + position: relative; + overflow: hidden; +} + +/* Individual session panel — one per tab, fills the panel container */ +.session-panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; } /* Terminal container — fills workspace */ diff --git a/frontend/src/css/terminal.css b/frontend/src/css/terminal.css index f7d1f89..c26a560 100644 --- a/frontend/src/css/terminal.css +++ b/frontend/src/css/terminal.css @@ -67,37 +67,50 @@ } /* xterm.js overrides for consistent look */ -.terminal-container .xterm { +.terminal-container .xterm, +.session-panel .xterm { height: 100%; - padding: 0; + padding: var(--space-1); } -.terminal-container .xterm-viewport { +.terminal-container .xterm-screen, +.session-panel .xterm-screen { + height: 100%; +} + +.terminal-container .xterm-viewport, +.session-panel .xterm-viewport { /* Allow xterm's own scrollbar to work */ overflow-y: auto !important; } -.terminal-container .xterm-viewport::-webkit-scrollbar { +.terminal-container .xterm-viewport::-webkit-scrollbar, +.session-panel .xterm-viewport::-webkit-scrollbar { width: 6px; } -.terminal-container .xterm-viewport::-webkit-scrollbar-track { +.terminal-container .xterm-viewport::-webkit-scrollbar-track, +.session-panel .xterm-viewport::-webkit-scrollbar-track { background: transparent; } -.terminal-container .xterm-viewport::-webkit-scrollbar-thumb { +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb, +.session-panel .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--radius-full); } -.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover { +.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover, +.session-panel .xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } /* Ensure cursor blink is visible */ -.terminal-container .xterm-cursor-layer { +.terminal-container .xterm-cursor-layer, +.session-panel .xterm-cursor-layer { z-index: 1; } /* Selection styling within terminal */ -.terminal-container .xterm .xterm-selection div { +.terminal-container .xterm .xterm-selection div, +.session-panel .xterm .xterm-selection div { background: var(--accent-strong) !important; } diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 8bdfab2..f8496c3 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -295,8 +295,9 @@ class App { searchInput.addEventListener('input', (e) => this.sidebar.search(e.target.value)); } - // Window resize → re-fit active terminal - window.addEventListener('resize', () => this.terminals.fitActive()); + // Window resize → re-fit active terminal (debounced) + const { debounce } = window.NexusTerminal; + window.addEventListener('resize', debounce(() => this.terminals.fitActive(), 100)); } // ── Connection lifecycle ───────────────────────────────────────── diff --git a/frontend/src/js/notifications.js b/frontend/src/js/notifications.js index 87d824f..9072d19 100644 --- a/frontend/src/js/notifications.js +++ b/frontend/src/js/notifications.js @@ -7,12 +7,12 @@ const { createElement, escapeHtml } = window.NexusTerminal; -/** Map of notification type to SVG icon path data. */ +/** Small SVG icons for each notification type (16×16 viewBox). */ const ICONS = { - success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', - error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', - warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', - info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + success: '✓', + error: '✕', + warning: '!', + info: 'i', }; class Notifications { @@ -20,106 +20,77 @@ class Notifications { /** @type {HTMLElement} */ this.container = document.getElementById('notifications'); if (!this.container) { - // Create the container if it does not yet exist this.container = createElement('div', 'notifications-container', { id: 'notifications' }); document.body.appendChild(this.container); } } /** - * Displays a toast notification. + * Displays a notification. * @param {string} message - Notification text. * @param {'info'|'success'|'warning'|'error'} [type='info'] - Severity. - * @param {number} [duration=4000] - Auto-dismiss delay in ms. Pass 0 to keep until dismissed. + * @param {number} [duration=4000] - Auto-dismiss delay in ms. */ show(message, type = 'info', duration = 4000) { - const toast = createElement('div', `toast toast-${type}`); + const el = createElement('div', `notification notification-${type}`); // Icon - const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - iconSvg.setAttribute('class', 'toast-icon'); - iconSvg.setAttribute('viewBox', '0 0 24 24'); - iconSvg.setAttribute('fill', 'none'); - iconSvg.setAttribute('stroke', 'currentColor'); - iconSvg.setAttribute('stroke-width', '2'); - const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - iconPath.setAttribute('stroke-linecap', 'round'); - iconPath.setAttribute('stroke-linejoin', 'round'); - iconPath.setAttribute('d', ICONS[type] || ICONS.info); - iconSvg.appendChild(iconPath); - toast.appendChild(iconSvg); + const icon = createElement('span', 'notification-icon', { + textContent: ICONS[type] || ICONS.info, + }); + el.appendChild(icon); - // Message - const msg = createElement('span', 'toast-message', { textContent: message }); - toast.appendChild(msg); + // Body + const body = createElement('div', 'notification-body'); + const msg = createElement('span', 'notification-message', { textContent: message }); + body.appendChild(msg); + el.appendChild(body); // Dismiss button - const close = createElement('button', 'toast-close', { textContent: '×', 'aria-label': 'Dismiss' }); - close.addEventListener('click', () => this._dismiss(toast)); - toast.appendChild(close); + const close = createElement('button', 'notification-close', { + textContent: '×', + 'aria-label': 'Dismiss', + }); + close.addEventListener('click', () => this._dismiss(el)); + el.appendChild(close); // Insert at the top of the stack - this.container.prepend(toast); - - // Trigger enter animation on next frame - requestAnimationFrame(() => { - toast.classList.add('toast-enter'); - }); + this.container.prepend(el); // Auto-dismiss if (duration > 0) { - toast._timer = setTimeout(() => this._dismiss(toast), duration); + el._timer = setTimeout(() => this._dismiss(el), duration); } } - /** - * Shorthand: success notification. - * @param {string} message - */ - success(message) { - this.show(message, 'success'); - } + /** @param {string} message */ + success(message) { this.show(message, 'success'); } + + /** @param {string} message */ + error(message) { this.show(message, 'error', 6000); } + + /** @param {string} message */ + warning(message) { this.show(message, 'warning'); } + + /** @param {string} message */ + info(message) { this.show(message, 'info'); } /** - * Shorthand: error notification (longer display time). - * @param {string} message - */ - error(message) { - this.show(message, 'error', 6000); - } - - /** - * Shorthand: warning notification. - * @param {string} message - */ - warning(message) { - this.show(message, 'warning'); - } - - /** - * Shorthand: informational notification. - * @param {string} message - */ - info(message) { - this.show(message, 'info'); - } - - /** - * Animates out and removes a toast element. - * @param {HTMLElement} toast + * Animates out and removes a notification element. + * @param {HTMLElement} el * @private */ - _dismiss(toast) { - if (toast._dismissed) return; - toast._dismissed = true; - clearTimeout(toast._timer); + _dismiss(el) { + if (el._dismissed) return; + el._dismissed = true; + clearTimeout(el._timer); - toast.classList.add('toast-exit'); - toast.addEventListener('animationend', () => toast.remove(), { once: true }); + el.classList.add('is-leaving'); + el.addEventListener('animationend', () => el.remove(), { once: true }); // Safety net — remove after 500 ms even if animation event does not fire setTimeout(() => { - if (toast.parentNode) toast.remove(); + if (el.parentNode) el.remove(); }, 500); } } diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index a47919b..d1866d9 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -151,7 +151,7 @@ class Sidebar { } // Protocol colour dot - const dot = createElement('span', `sidebar-dot ${protocolColour(profile.protocol)}`); + const dot = createElement('span', `protocol-indicator ${protocolColour(profile.protocol)}`); item.appendChild(dot); // Text content @@ -164,7 +164,7 @@ class Sidebar { const subtitle = profile.host ? `${profile.host}:${profile.port}` : profile.protocol.toUpperCase(); - const subEl = createElement('span', 'sidebar-item-sub', { + const subEl = createElement('span', 'sidebar-item-host', { textContent: truncate(subtitle, 30), }); textWrapper.appendChild(subEl); @@ -172,7 +172,7 @@ class Sidebar { // Favourite star if (profile.favourite) { - const star = createElement('span', 'sidebar-star', { textContent: '★' }); + const star = createElement('span', 'sidebar-item-star is-favourite', { textContent: '★' }); item.appendChild(star); } diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index ded0cc6..6008adc 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -446,10 +446,12 @@ class TabManager { _promptRename(sessionId) { const session = this.app.state.getSession(sessionId); const current = session?.tabName || ''; - const name = prompt('Tab name:', current); - if (name !== null && name.trim()) { - this.renameTab(sessionId, name.trim()); - } + // Use the app's custom prompt modal (window.prompt is unavailable in WKWebView) + this.app._inputPrompt('Rename Tab', 'Tab name', current).then((name) => { + if (name !== null && name.trim()) { + this.renameTab(sessionId, name.trim()); + } + }); } /** diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js index 17b812e..f950a86 100644 --- a/frontend/src/js/utils.js +++ b/frontend/src/js/utils.js @@ -207,9 +207,9 @@ function dismissContextMenus() { */ function protocolColour(protocol) { switch (protocol) { - case 'ssh': return 'protocol-ssh'; - case 'telnet': return 'protocol-telnet'; - case 'raw': return 'protocol-raw'; + case 'ssh': return 'proto-ssh'; + case 'telnet': return 'proto-telnet'; + case 'raw': return 'proto-raw'; default: return ''; } }