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:
2026-04-15 00:00:00 +01:00
commit 35bc9c75b1
36 changed files with 6226 additions and 0 deletions
+546
View File
@@ -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;
});
}
}