Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client
- go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid - cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e) - internal/app/app.go: lifecycle (Startup/Shutdown/DomReady) - internal/models/models.go: full model definitions (Profile, SSHConfig, TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.) - internal/models/defaults.go: default values and NewProfile factory - internal/validation/validation.go: thorough field-level validation - frontend/embed.go: embedded frontend assets - Updated existing storage, secrets, config to match new model structure
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Nexus Terminal — tab management.
|
||||
* Handles the tab bar: adding, removing, reordering, renaming,
|
||||
* pinning tabs, and context menus.
|
||||
*/
|
||||
|
||||
import { createElement, escapeHtml, protocolColour, stateLabel, dismissContextMenus } from './utils.js';
|
||||
|
||||
export class TabManager {
|
||||
/**
|
||||
* @param {import('./app.js').App} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {HTMLElement} The tab bar container. */
|
||||
this.tabBar = document.getElementById('tab-bar');
|
||||
|
||||
/** @type {HTMLElement} The container for terminal/viewer panels. */
|
||||
this.panelContainer = document.getElementById('panel-container');
|
||||
|
||||
/** @type {string[]} Ordered list of session IDs. */
|
||||
this.order = [];
|
||||
|
||||
this._setupDragAndDrop();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adds a new tab for a session and makes it active.
|
||||
* @param {Object} sessionInfo
|
||||
*/
|
||||
addTab(sessionInfo) {
|
||||
this.order.push(sessionInfo.id);
|
||||
this._renderTab(sessionInfo);
|
||||
|
||||
// Create panel container for this session's terminal/viewer
|
||||
const panel = createElement('div', 'session-panel', {
|
||||
id: `panel-${sessionInfo.id}`,
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
});
|
||||
panel.style.display = 'none';
|
||||
this.panelContainer.appendChild(panel);
|
||||
|
||||
this.setActive(sessionInfo.id);
|
||||
this._updateEmptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tab and its panel.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
removeTab(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx !== -1) this.order.splice(idx, 1);
|
||||
|
||||
// Remove tab element
|
||||
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl) tabEl.remove();
|
||||
|
||||
// Remove panel
|
||||
const panel = document.getElementById(`panel-${sessionId}`);
|
||||
if (panel) panel.remove();
|
||||
|
||||
// If this was the active tab, activate the nearest neighbour
|
||||
if (this.app.state.activeSessionId === sessionId) {
|
||||
const next = this.order.length > 0
|
||||
? this.order[Math.min(idx, this.order.length - 1)]
|
||||
: null;
|
||||
if (next) {
|
||||
this.setActive(next);
|
||||
} else {
|
||||
this.app.state.setActiveSession(null);
|
||||
}
|
||||
}
|
||||
|
||||
this._updateEmptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the visible tab/panel to the given session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
setActive(sessionId) {
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
|
||||
// Highlight active tab
|
||||
this.tabBar.querySelectorAll('.tab').forEach((t) => {
|
||||
t.classList.toggle('tab-active', t.dataset.sessionId === sessionId);
|
||||
});
|
||||
|
||||
// Show corresponding panel, hide others
|
||||
this.panelContainer.querySelectorAll('.session-panel').forEach((p) => {
|
||||
p.style.display = p.dataset.sessionId === sessionId ? 'flex' : 'none';
|
||||
});
|
||||
|
||||
// Notify sidebar
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session) {
|
||||
this.app.sidebar.highlightActive(session.profileId);
|
||||
}
|
||||
|
||||
// Fit the terminal for the newly active session
|
||||
this.app.terminals.fitActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the currently active tab.
|
||||
*/
|
||||
closeActive() {
|
||||
if (this.app.state.activeSessionId) {
|
||||
this.closeTab(this.app.state.activeSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects and closes a tab by session ID.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async closeTab(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
|
||||
// Ask for confirmation if connected and the preference is set
|
||||
if (session && session.state === 'connected' && this.app.state.preferences.confirmOnDisconnect) {
|
||||
const ok = confirm('Disconnect and close this session?');
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.go.api.Service.CloseSession(sessionId);
|
||||
} catch (_) {
|
||||
// Session may already be closed — carry on
|
||||
}
|
||||
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a tab.
|
||||
* @param {string} sessionId
|
||||
* @param {string} name
|
||||
*/
|
||||
renameTab(sessionId, name) {
|
||||
this.app.state.updateSession(sessionId, { tabName: name });
|
||||
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl) {
|
||||
tabEl.querySelector('.tab-name').textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a tab (moves it to the front).
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
pinTab(sessionId) {
|
||||
this.app.state.updateSession(sessionId, { pinned: true });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins a tab.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
unpinTab(sessionId) {
|
||||
this.app.state.updateSession(sessionId, { pinned: false });
|
||||
this._reorderDOM();
|
||||
this._updateTabStyles(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a tab one position to the left.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
moveLeft(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx > 0) {
|
||||
[this.order[idx - 1], this.order[idx]] = [this.order[idx], this.order[idx - 1]];
|
||||
this._reorderDOM();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a tab one position to the right.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
moveRight(sessionId) {
|
||||
const idx = this.order.indexOf(sessionId);
|
||||
if (idx >= 0 && idx < this.order.length - 1) {
|
||||
[this.order[idx], this.order[idx + 1]] = [this.order[idx + 1], this.order[idx]];
|
||||
this._reorderDOM();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new session with the same profile as the given tab.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
async duplicateTab(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (session && session.profileId) {
|
||||
await this.app.connectProfile(session.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visual state of a tab when session state changes.
|
||||
* @param {string} sessionId
|
||||
* @param {string} state - connecting | connected | disconnected | error
|
||||
* @param {string} [error]
|
||||
*/
|
||||
updateSessionState(sessionId, state, error) {
|
||||
this.app.state.updateSession(sessionId, { state, error: error || '' });
|
||||
|
||||
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (!tabEl) return;
|
||||
|
||||
const dot = tabEl.querySelector('.tab-status');
|
||||
if (dot) {
|
||||
dot.className = `tab-status status-${state}`;
|
||||
dot.title = stateLabel(state);
|
||||
}
|
||||
|
||||
if (state === 'error' && error) {
|
||||
this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a session closed event from the backend.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
handleSessionClosed(sessionId) {
|
||||
this.updateSessionState(sessionId, 'disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a right-click context menu for a tab.
|
||||
* @param {string} sessionId
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
showContextMenu(sessionId, x, y) {
|
||||
dismissContextMenus();
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const menu = createElement('div', 'context-menu');
|
||||
menu.style.left = `${x}px`;
|
||||
menu.style.top = `${y}px`;
|
||||
|
||||
const isPinned = session.pinned;
|
||||
|
||||
const items = [
|
||||
{ label: 'Rename…', action: () => this._promptRename(sessionId) },
|
||||
{ label: 'Duplicate', action: () => this.duplicateTab(sessionId) },
|
||||
{ divider: true },
|
||||
{ label: isPinned ? 'Unpin' : 'Pin', action: () => isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId) },
|
||||
{ label: 'Move Left', action: () => this.moveLeft(sessionId) },
|
||||
{ label: 'Move Right', action: () => this.moveRight(sessionId) },
|
||||
{ divider: true },
|
||||
{
|
||||
label: 'Reconnect',
|
||||
action: () => this._reconnect(sessionId),
|
||||
disabled: session.state === 'connected' || session.state === 'connecting',
|
||||
},
|
||||
{ divider: true },
|
||||
{ label: 'Close', className: 'context-menu-danger', action: () => this.closeTab(sessionId) },
|
||||
];
|
||||
|
||||
for (const entry of items) {
|
||||
if (entry.divider) {
|
||||
menu.appendChild(createElement('div', 'context-menu-divider'));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
|
||||
textContent: entry.label,
|
||||
});
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('context-menu-disabled');
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
dismissContextMenus();
|
||||
entry.action();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Keep within viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||
if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current tab ordering.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getTabOrder() {
|
||||
return [...this.order];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID of the next tab (wrapping round).
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getNextTab() {
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx + 1) % this.order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID of the previous tab (wrapping round).
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getPrevTab() {
|
||||
if (this.order.length === 0) return null;
|
||||
const idx = this.order.indexOf(this.app.state.activeSessionId);
|
||||
return this.order[(idx - 1 + this.order.length) % this.order.length];
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates and appends a tab element.
|
||||
* @param {Object} sessionInfo
|
||||
* @private
|
||||
*/
|
||||
_renderTab(sessionInfo) {
|
||||
const tab = createElement('div', 'tab', {
|
||||
dataset: { sessionId: sessionInfo.id },
|
||||
draggable: 'true',
|
||||
});
|
||||
|
||||
// Protocol badge
|
||||
const badge = createElement('span', `tab-badge ${protocolColour(sessionInfo.protocol)}`, {
|
||||
textContent: sessionInfo.protocol.toUpperCase(),
|
||||
});
|
||||
tab.appendChild(badge);
|
||||
|
||||
// Name
|
||||
const name = createElement('span', 'tab-name', {
|
||||
textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(),
|
||||
});
|
||||
tab.appendChild(name);
|
||||
|
||||
// Status dot
|
||||
const status = createElement('span', `tab-status status-${sessionInfo.state || 'connecting'}`, {
|
||||
title: stateLabel(sessionInfo.state),
|
||||
});
|
||||
tab.appendChild(status);
|
||||
|
||||
// Pin indicator
|
||||
if (sessionInfo.pinned) {
|
||||
tab.classList.add('tab-pinned');
|
||||
}
|
||||
|
||||
// Close button
|
||||
const closeBtn = createElement('button', 'tab-close', {
|
||||
textContent: '×',
|
||||
'aria-label': 'Close tab',
|
||||
});
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(sessionInfo.id);
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
// Click → activate
|
||||
tab.addEventListener('click', () => this.setActive(sessionInfo.id));
|
||||
|
||||
// Right-click → context menu
|
||||
tab.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
this.showContextMenu(sessionInfo.id, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
this.tabBar.appendChild(tab);
|
||||
}
|
||||
|
||||
// ── Internal helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reorders the tab-bar DOM elements to match `this.order`,
|
||||
* with pinned tabs always first.
|
||||
* @private
|
||||
*/
|
||||
_reorderDOM() {
|
||||
// Sort: pinned first, then by order index
|
||||
const sorted = [...this.order].sort((a, b) => {
|
||||
const sa = this.app.state.getSession(a);
|
||||
const sb = this.app.state.getSession(b);
|
||||
const pinnedA = sa?.pinned ? 0 : 1;
|
||||
const pinnedB = sb?.pinned ? 0 : 1;
|
||||
if (pinnedA !== pinnedB) return pinnedA - pinnedB;
|
||||
return this.order.indexOf(a) - this.order.indexOf(b);
|
||||
});
|
||||
|
||||
for (const id of sorted) {
|
||||
const el = this.tabBar.querySelector(`[data-session-id="${id}"]`);
|
||||
if (el) this.tabBar.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visual pin state on a tab element.
|
||||
* @param {string} sessionId
|
||||
* @private
|
||||
*/
|
||||
_updateTabStyles(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const tabEl = this.tabBar.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
if (tabEl && session) {
|
||||
tabEl.classList.toggle('tab-pinned', !!session.pinned);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides the empty-state panel based on whether tabs exist.
|
||||
* @private
|
||||
*/
|
||||
_updateEmptyState() {
|
||||
const empty = document.getElementById('empty-state');
|
||||
if (empty) {
|
||||
empty.style.display = this.order.length === 0 ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to rename a tab.
|
||||
* @param {string} sessionId
|
||||
* @private
|
||||
*/
|
||||
_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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to reconnect a disconnected session.
|
||||
* @param {string} sessionId
|
||||
* @private
|
||||
*/
|
||||
async _reconnect(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || !session.profileId) return;
|
||||
|
||||
// Close the current tab, then open a fresh connection
|
||||
this.app.terminals.destroyTerminal(sessionId);
|
||||
this.app.rawViewer.destroyViewer(sessionId);
|
||||
this.app.state.removeSession(sessionId);
|
||||
this.removeTab(sessionId);
|
||||
|
||||
await this.app.connectProfile(session.profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display label for a session's tab.
|
||||
* @param {string} sessionId
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_tabLabel(sessionId) {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
return session?.tabName || 'Session';
|
||||
}
|
||||
|
||||
// ── Drag and drop ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets up drag-and-drop reordering on the tab bar.
|
||||
* @private
|
||||
*/
|
||||
_setupDragAndDrop() {
|
||||
if (!this.tabBar) return;
|
||||
|
||||
let draggedId = null;
|
||||
|
||||
this.tabBar.addEventListener('dragstart', (e) => {
|
||||
const tab = e.target.closest('.tab');
|
||||
if (!tab) return;
|
||||
draggedId = tab.dataset.sessionId;
|
||||
tab.classList.add('tab-dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
this.tabBar.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const target = e.target.closest('.tab');
|
||||
if (!target || target.dataset.sessionId === draggedId) return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const midX = rect.left + rect.width / 2;
|
||||
target.classList.toggle('tab-drop-before', e.clientX < midX);
|
||||
target.classList.toggle('tab-drop-after', e.clientX >= midX);
|
||||
});
|
||||
|
||||
this.tabBar.addEventListener('dragleave', (e) => {
|
||||
const target = e.target.closest('.tab');
|
||||
if (target) {
|
||||
target.classList.remove('tab-drop-before', 'tab-drop-after');
|
||||
}
|
||||
});
|
||||
|
||||
this.tabBar.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target.closest('.tab');
|
||||
if (!target || !draggedId) return;
|
||||
|
||||
target.classList.remove('tab-drop-before', 'tab-drop-after');
|
||||
|
||||
const targetId = target.dataset.sessionId;
|
||||
if (targetId === draggedId) return;
|
||||
|
||||
// Reorder
|
||||
const fromIdx = this.order.indexOf(draggedId);
|
||||
const toIdx = this.order.indexOf(targetId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
|
||||
this.order.splice(fromIdx, 1);
|
||||
this.order.splice(toIdx, 0, draggedId);
|
||||
this._reorderDOM();
|
||||
});
|
||||
|
||||
this.tabBar.addEventListener('dragend', () => {
|
||||
this.tabBar.querySelectorAll('.tab').forEach((t) => {
|
||||
t.classList.remove('tab-dragging', 'tab-drop-before', 'tab-drop-after');
|
||||
});
|
||||
draggedId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user