(function () { /** * Nexus Terminal — tab management. * Handles the tab bar: adding, removing, reordering, renaming, * pinning tabs, and context menus. */ const { createElement, escapeHtml, protocolColour, stateLabel, dismissContextMenus, } = window.NexusTerminal; class TabManager { /** * @param {App} app */ constructor(app) { this.app = app; /** @type {HTMLElement} The inner tab list where tab elements are inserted. */ this.tabList = document.getElementById("tab-list"); /** @type {HTMLElement} The outer tab bar wrapper (used for drag-and-drop). */ 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); // Hide the empty-state BEFORE activating the panel so the // panel-container gets the full workspace height immediately. this._updateEmptyState(); this.setActive(sessionInfo.id); } /** * Removes a tab and its panel. * @param {string} sessionId */ removeTab(sessionId) { const idx = this.order.indexOf(sessionId); const wasActive = this.app.state.activeSessionId === sessionId; if (idx !== -1) this.order.splice(idx, 1); // Remove tab element const tabEl = this.tabList.querySelector( `[data-session-id="${sessionId}"]`, ); if (tabEl) tabEl.remove(); // Remove panel const panel = document.getElementById(`panel-${sessionId}`); if (panel) panel.remove(); // Activate the nearest neighbour if (wasActive) { 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 and clear activity indicator this.tabList.querySelectorAll(".tab").forEach((t) => { t.classList.toggle("tab-active", t.dataset.sessionId === sessionId); if (t.dataset.sessionId === sessionId) { t.classList.remove("tab-has-activity"); } }); // 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); } this.app.leviathan?.handleTabFocus?.(sessionId); // Fit and refresh the terminal for the newly active session. // Double-RAF ensures the panel has layout dimensions before fitting. requestAnimationFrame(() => { this.app.terminals.fitSession(sessionId); const entry = this.app.terminals.terminals.get(sessionId); if (entry) { entry.term.refresh(0, entry.term.rows - 1); } requestAnimationFrame(() => { this.app.terminals.focusTerminal(sessionId); }); }); } /** * Closes the currently active tab. */ closeActive() { if (this.app.state.activeSessionId) { this.closeTab(this.app.state.activeSessionId); } } /** * Reconnects a session by ID. * @param {string} sessionId */ reconnectSession(sessionId) { return this._reconnect(sessionId); } /** * 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 = await this._confirmClose(sessionId); if (!ok) return; } try { await window.NexusTerminal.wailsService.DisconnectSession(sessionId); } catch (_) { // Session may already be closed — carry on } this.app.terminals.destroyTerminal(sessionId); this.app.rawViewer.destroyViewer(sessionId); window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); this.app.leviathan?.destroyPanel(sessionId); this.removeTab(sessionId); this.app.state.removeSession(sessionId); } /** * Shows a confirmation modal before closing a connected session. * @param {string} sessionId * @returns {Promise} * @private */ _confirmClose(sessionId) { const createElement = window.NexusTerminal.createElement; const label = this._tabLabel(sessionId); return new Promise((resolve) => { const backdrop = createElement("div", "modal-backdrop"); const modal = createElement("div", "modal modal-sm"); modal.innerHTML = ` `; backdrop.appendChild(modal); document.body.appendChild(backdrop); requestAnimationFrame(() => { backdrop.classList.add("is-visible"); modal.classList.add("is-visible"); }); const close = (val) => { backdrop.classList.remove("is-visible"); modal.classList.remove("is-visible"); setTimeout(() => backdrop.remove(), 200); resolve(val); }; modal .querySelector(".modal-close") .addEventListener("click", () => close(false)); modal .querySelector(".confirm-cancel") .addEventListener("click", () => close(false)); modal .querySelector(".confirm-ok") .addEventListener("click", () => close(true)); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(false); }); }); } /** * Renames a tab. * @param {string} sessionId * @param {string} name */ renameTab(sessionId, name) { this.app.state.updateSession(sessionId, { tabName: name }); const tabEl = this.tabList.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.tabList.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]; } /** * Marks a background tab as having new activity. * Does nothing if the session is currently active. * @param {string} sessionId */ markActivity(sessionId) { if (sessionId === this.app.state.activeSessionId) return; const tabEl = this.tabList.querySelector( `[data-session-id="${sessionId}"]`, ); if (tabEl && !tabEl.classList.contains("tab-has-activity")) { tabEl.classList.add("tab-has-activity"); } } /** * 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 protoLabel = sessionInfo.protocol === "github" ? "GitHub" : sessionInfo.protocol === "local" ? "Local" : sessionInfo.protocol.toUpperCase(); const badge = createElement( "span", `tab-badge badge-${sessionInfo.protocol}`, { textContent: protoLabel, }, ); 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); // Activity indicator (hidden by default, shown when background tab has output) const activity = createElement("span", "tab-activity"); tab.appendChild(activity); // Pin indicator if (sessionInfo.pinned) { tab.classList.add("tab-pinned"); } // SFTP toggle button (SSH sessions only) if (sessionInfo.protocol === "ssh") { const sftpBtn = createElement("button", "tab-sftp", { textContent: "📁", title: "Toggle SFTP file browser", "aria-label": "Toggle SFTP", }); sftpBtn.addEventListener("click", (e) => { e.stopPropagation(); this.app.sftp.toggle(sessionInfo.id); }); tab.appendChild(sftpBtn); } // 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.tabList.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.tabList.querySelector(`[data-session-id="${id}"]`); if (el) this.tabList.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.tabList.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 || ""; // 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()); } }); } /** * 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); window.NexusTerminal.GitHubViewer.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.tabList) return; let draggedId = null; this.tabList.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.tabList.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.tabList.addEventListener("dragleave", (e) => { const target = e.target.closest(".tab"); if (target) { target.classList.remove("tab-drop-before", "tab-drop-after"); } }); this.tabList.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.tabList.addEventListener("dragend", () => { this.tabList.querySelectorAll(".tab").forEach((t) => { t.classList.remove( "tab-dragging", "tab-drop-before", "tab-drop-after", ); }); draggedId = null; }); } } window.NexusTerminal = window.NexusTerminal || {}; window.NexusTerminal.TabManager = TabManager; })();