GitHub improvements

This commit is contained in:
Andy Dixon
2026-04-20 13:40:00 +01:00
parent 1d97e907e0
commit c5debd9c77
20 changed files with 2525 additions and 1238 deletions
+236 -146
View File
@@ -1,104 +1,166 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Terminal</title>
<!-- Vendor styles -->
<link rel="stylesheet" href="/src/css/lib/xterm.css">
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
<!-- Design system -->
<link rel="stylesheet" href="/src/css/tokens.css">
<link rel="stylesheet" href="/src/css/base.css">
<link rel="stylesheet" href="/src/css/components.css">
<link rel="stylesheet" href="/src/css/layout.css">
<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/recordings.css">
<link rel="stylesheet" href="/src/css/sftp.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">
</head>
<body>
<link rel="stylesheet" href="/src/css/tokens.css" />
<link rel="stylesheet" href="/src/css/base.css" />
<link rel="stylesheet" href="/src/css/components.css" />
<link rel="stylesheet" href="/src/css/layout.css" />
<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/recordings.css" />
<link rel="stylesheet" href="/src/css/sftp.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" />
</head>
<body>
<div id="app" class="app">
<!-- ============================================================
<!-- ============================================================
Sidebar — connection manager
============================================================ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header wails-drag">
<div class="sidebar-logo">
<!-- Terminal prompt icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
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>
<span class="sidebar-title">Nexus Terminal</span>
</div>
</div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header wails-drag">
<div class="sidebar-logo">
<!-- Terminal prompt icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
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>
<span class="sidebar-title">Nexus Terminal</span>
</div>
</div>
<div class="sidebar-search">
<div class="search-wrapper">
<input type="text"
class="search-input"
id="profile-search"
placeholder="Search connections…"
autocomplete="off"
spellcheck="false">
</div>
</div>
<div class="sidebar-search">
<div class="search-wrapper">
<input
type="text"
class="search-input"
id="profile-search"
placeholder="Search connections…"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<nav class="sidebar-nav" id="sidebar-nav">
<!-- Dynamically managed by sidebar.js -->
<div id="sidebar-profiles" class="sidebar-profiles"></div>
</nav>
<nav class="sidebar-nav" id="sidebar-nav">
<!-- Dynamically managed by sidebar.js -->
<div id="sidebar-profiles" class="sidebar-profiles"></div>
</nav>
<div class="sidebar-footer">
<button class="btn btn-ghost btn-sm" id="btn-new-connection" title="New Connection">
<span>+ New</span>
</button>
<button class="btn btn-ghost btn-icon btn-sm" id="btn-import" title="Import Profiles">
<!-- Upload icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button class="btn btn-ghost btn-icon btn-sm" id="btn-export" title="Export Profiles">
<!-- Download icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button class="btn btn-ghost btn-icon btn-sm" id="btn-recordings" title="Session Recordings">
<!-- Film icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
<!-- Cog icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
<div class="sidebar-footer">
<button
class="btn btn-ghost btn-sm"
id="btn-new-connection"
title="New Connection"
>
<span>+ New</span>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-import"
title="Import Profiles"
>
<!-- Upload icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-export"
title="Export Profiles"
>
<!-- Download icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-recordings"
title="Session Recordings"
>
<!-- Film icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-settings"
title="Settings"
>
<!-- Cog icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
@@ -114,73 +176,101 @@
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
</div>
</aside>
1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
</div>
</aside>
<!-- ============================================================
<!-- ============================================================
Tab bar — session tabs
============================================================ -->
<div class="tab-bar" id="tab-bar">
<div class="tab-list" id="tab-list">
<!-- Tabs are injected dynamically by JS -->
</div>
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
<button class="tab-broadcast" id="btn-broadcast" title="Toggle Broadcast Mode (Ctrl+Shift+B)">
<!-- Broadcast icon (loudspeaker) -->
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<div class="tab-bar" id="tab-bar">
<div class="tab-list" id="tab-list">
<!-- Tabs are injected dynamically by JS -->
</div>
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
<button
class="tab-broadcast"
id="btn-broadcast"
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
>
<!-- Broadcast icon (loudspeaker) -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
<!-- ============================================================
<!-- ============================================================
Workspace — terminal area
============================================================ -->
<main class="workspace" id="workspace">
<!-- Empty state — shown when no sessions are active -->
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">
<!-- Terminal icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" 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>
</div>
<h2>No active sessions</h2>
<p>Select a connection from the sidebar or create a new one to get started.</p>
<div class="empty-state-actions">
<button class="btn btn-primary" id="btn-empty-connect">New Connection</button>
<button class="btn btn-secondary" id="btn-empty-quick">Quick Connect</button>
</div>
</div>
<main class="workspace" id="workspace">
<!-- Empty state — shown when no sessions are active -->
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">
<!-- Terminal icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
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>
</div>
<h2>No active sessions</h2>
<p>
Select a connection from the sidebar or create a new one to get
started.
</p>
<div class="empty-state-actions">
<button class="btn btn-primary" id="btn-empty-connect">
New Connection
</button>
<button class="btn btn-secondary" id="btn-empty-quick">
Quick Connect
</button>
</div>
</div>
<!-- Session panels are injected here dynamically by tabs.js -->
<div id="panel-container" class="panel-container"></div>
</main>
<!-- Session panels are injected here dynamically by tabs.js -->
<div id="panel-container" class="panel-container"></div>
</main>
<!-- ============================================================
<!-- ============================================================
Status bar
============================================================ -->
<footer class="status-bar" id="status-bar">
<span id="status-connection">
<span class="status-dot is-disconnected"></span>
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<span id="status-vault">🔒 Locked</span>
</footer>
<footer class="status-bar" id="status-bar">
<span id="status-connection">
<span class="status-dot is-disconnected"></span>
Disconnected
</span>
<span id="status-protocol"></span>
<span id="status-session-count">0 sessions</span>
<span class="status-spacer"></span>
<span id="status-vault">🔒 Locked</span>
</footer>
</div>
<!-- Modal container (dynamically populated) -->
@@ -215,5 +305,5 @@
<script src="/src/js/splitpane.js"></script>
<script src="/src/js/snippets.js"></script>
<script src="/src/js/app.js"></script>
</body>
</body>
</html>
+19 -19
View File
@@ -4,38 +4,38 @@
*
* Run automatically via the postinstall hook in package.json.
*/
'use strict';
"use strict";
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
const frontendDir = path.resolve(__dirname, '..');
const frontendDir = path.resolve(__dirname, "..");
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
const assets = [
{
src: 'xterm/css/xterm.css',
dest: 'src/css/lib/xterm.css',
src: "xterm/css/xterm.css",
dest: "src/css/lib/xterm.css",
},
{
src: 'xterm/lib/xterm.js',
dest: 'src/js/lib/xterm.min.js',
src: "xterm/lib/xterm.js",
dest: "src/js/lib/xterm.min.js",
},
{
src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
dest: 'src/js/lib/xterm-addon-fit.min.js',
src: "xterm-addon-fit/lib/xterm-addon-fit.js",
dest: "src/js/lib/xterm-addon-fit.min.js",
},
{
src: 'xterm-addon-search/lib/xterm-addon-search.js',
dest: 'src/js/lib/xterm-addon-search.min.js',
src: "xterm-addon-search/lib/xterm-addon-search.js",
dest: "src/js/lib/xterm-addon-search.min.js",
},
{
src: 'xterm-addon-unicode11/lib/xterm-addon-unicode11.js',
dest: 'src/js/lib/xterm-addon-unicode11.min.js',
src: "xterm-addon-unicode11/lib/xterm-addon-unicode11.js",
dest: "src/js/lib/xterm-addon-unicode11.min.js",
},
{
src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
dest: 'src/js/lib/xterm-addon-web-links.min.js',
src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
dest: "src/js/lib/xterm-addon-web-links.min.js",
},
];
@@ -53,7 +53,7 @@ function ensureDir(filePath) {
* Copies a single asset, logging the result.
*/
function copyAsset(entry) {
const srcPath = path.join(frontendDir, 'node_modules', entry.src);
const srcPath = path.join(frontendDir, "node_modules", entry.src);
const destPath = path.join(frontendDir, entry.dest);
if (!fs.existsSync(srcPath)) {
@@ -69,7 +69,7 @@ function copyAsset(entry) {
// --- Main ---
console.log('[copy-assets] Copying xterm library files…');
console.log("[copy-assets] Copying xterm library files…");
let failures = 0;
for (const entry of assets) {
@@ -84,4 +84,4 @@ if (failures > 0) {
process.exit(1);
}
console.log('[copy-assets] All assets copied successfully.');
console.log("[copy-assets] All assets copied successfully.");
+35
View File
@@ -174,6 +174,12 @@
margin-bottom: 2px;
}
.github-repo-commit {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: 4px;
}
.github-repo-name {
font-size: var(--font-size-sm);
font-weight: 600;
@@ -190,6 +196,35 @@
letter-spacing: 0.03em;
}
.github-repo-actions {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.github-repo-download,
.github-repo-visit {
padding: 0 6px;
height: 18px;
border-radius: var(--radius-full);
border: 1px solid var(--border-subtle);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 10px;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.github-repo-download:hover,
.github-repo-visit:hover {
background: var(--bg-hover);
color: var(--protocol-github);
border-color: var(--protocol-github);
}
.github-repo-private {
background: rgba(255, 167, 38, 0.12);
color: #ffa726;
+37
View File
@@ -53,6 +53,34 @@
padding-bottom: var(--space-2);
}
/* -----------------------------------------------------------------------
Bulk actions bar (multi-select)
----------------------------------------------------------------------- */
.sidebar-bulk-actions {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-bulk-count {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-semibold);
}
.sidebar-bulk-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
/* -----------------------------------------------------------------------
Sections (Favourites, Recent, All Connections)
----------------------------------------------------------------------- */
@@ -167,6 +195,15 @@
background: var(--bg-active);
}
.sidebar-item-selected {
background: var(--accent-muted);
box-shadow: inset 0 0 0 1px var(--accent);
}
.sidebar-item-selected .sidebar-item-name {
color: var(--text-primary);
}
/* Active / selected state */
.sidebar-item.active {
background: var(--accent-muted);
+981 -705
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -53,6 +53,18 @@ class GitHubViewer {
}
}
/**
* Updates the status line for a session.
* @param {string} sessionID
* @param {string} message
*/
setStatus(sessionID, message) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.setStatus(message);
}
}
/**
* Handles an error event for a session.
* @param {string} sessionID
@@ -165,6 +177,16 @@ class GitHubViewerInstance {
this._renderList();
}
/**
* Updates the status line with a custom message.
* @param {string} message
*/
setStatus(message) {
if (!this._statusEl) return;
this._statusEl.classList.remove('github-viewer-status-error');
this._statusEl.textContent = message || '';
}
/**
* Displays an error message in the status bar.
* @param {string} message
@@ -272,6 +294,7 @@ class GitHubViewerInstance {
nameEl.textContent = repo.name;
topRow.appendChild(nameEl);
if (repo.private) {
const badge = createElement('span', 'github-repo-badge github-repo-private');
badge.textContent = 'Private';
@@ -288,8 +311,46 @@ class GitHubViewerInstance {
topRow.appendChild(badge);
}
const actions = createElement('div', 'github-repo-actions');
const downloadBtn = createElement('button', 'github-repo-download', {
textContent: 'ZIP',
title: 'Download repository as zip',
});
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._downloadRepo(repo);
});
actions.appendChild(downloadBtn);
const visitLink = createElement('button', 'github-repo-visit', {
textContent: 'Visit',
title: 'Open repository in browser',
});
visitLink.addEventListener('click', (e) => {
e.stopPropagation();
if (repo.htmlURL) {
if (window.runtime && window.runtime.BrowserOpenURL) {
window.runtime.BrowserOpenURL(repo.htmlURL);
} else {
window.open(repo.htmlURL, '_blank');
}
}
});
actions.appendChild(visitLink);
topRow.appendChild(actions);
card.appendChild(topRow);
if (repo.latestCommitter && repo.latestCommitAt) {
const commitLine = createElement('div', 'github-repo-commit');
const when = new Date(repo.latestCommitAt);
const whenText = isNaN(when.getTime()) ? repo.latestCommitAt : when.toLocaleString();
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
card.appendChild(commitLine);
}
// Description
if (repo.description) {
const desc = createElement('div', 'github-repo-desc');
@@ -324,6 +385,25 @@ class GitHubViewerInstance {
return card;
}
async _downloadRepo(repo) {
if (!this._statusEl) return;
const ref = repo.defaultBranch || '';
this._statusEl.classList.remove('github-viewer-status-error');
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
try {
await window.go.api.Service.DownloadGitHubRepoArchive(
this.sessionID,
repo.fullName,
ref,
);
this._updateStatus();
} catch (err) {
this._statusEl.textContent = `⚠ Download failed: ${err}`;
this._statusEl.classList.add('github-viewer-status-error');
}
}
async _manualRefresh() {
if (!this._statusEl) return;
this._statusEl.textContent = 'Refreshing…';
+68
View File
@@ -183,12 +183,19 @@ class ProfileEditor {
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
const deleteBtn = !isNew
? createElement('button', 'btn btn-danger', { textContent: 'Delete' })
: null;
cancelBtn.addEventListener('click', () => this.close());
testBtn.addEventListener('click', () => this._testConnection());
saveBtn.addEventListener('click', () => this._handleSave(false));
connectBtn.addEventListener('click', () => this._handleSave(true));
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this._handleDelete());
}
if (deleteBtn) footer.appendChild(deleteBtn);
footer.appendChild(cancelBtn);
footer.appendChild(testBtn);
footer.appendChild(saveBtn);
@@ -1464,6 +1471,22 @@ class ProfileEditor {
}
}
/** @private */
async _handleDelete() {
const ok = await this._confirmDelete();
if (!ok) return;
try {
await window.go.api.Service.DeleteProfile(this._profile.id);
this.app.state.removeProfile(this._profile.id);
this.app.sidebar.render();
this.app.notifications.success('Profile deleted');
this.close();
} catch (err) {
this.app.notifications.error('Failed to delete profile: ' + err);
}
}
/** @private */
async _testConnection() {
this._collectFormValues();
@@ -1485,6 +1508,51 @@ class ProfileEditor {
}
}
/**
* Shows a confirmation modal for profile deletion.
* @returns {Promise<boolean>}
* @private
*/
_confirmDelete() {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Delete Profile</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Delete <strong>${label}</strong>? This cannot be undone.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">Delete</button>
</div>
`;
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); });
});
}
// ── Utilities ────────────────────────────────────────────────────
/**
+11 -2
View File
@@ -42,9 +42,9 @@ class Settings {
/**
* Opens the settings modal.
*/
async open() {
async open(tabKey) {
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
this._activeTab = 'general';
this._activeTab = tabKey || this._activeTab || 'general';
try {
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
@@ -55,6 +55,15 @@ class Settings {
this._render();
}
/**
* Opens the settings modal on a specific tab.
* @param {string} tabKey
*/
async openTab(tabKey) {
this._activeTab = tabKey || 'general';
return this.open(this._activeTab);
}
/**
* Closes the settings modal.
*/
+314 -2
View File
@@ -19,11 +19,22 @@ class Sidebar {
this.el = document.getElementById('sidebar-profiles');
this.searchQuery = '';
/** @type {Set<string>} */
this.selectedProfiles = new Set();
// Section collapse state
this.collapsedSections = new Set();
// Dismiss context menus on any outside click
document.addEventListener('click', () => dismissContextMenus());
if (this.el) {
this.el.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) {
this._clearSelection();
}
});
}
}
// ── Public API ───────────────────────────────────────────────────
@@ -41,6 +52,11 @@ class Sidebar {
this.el.innerHTML = '';
const profiles = this._filteredProfiles();
this._syncSelection(profiles);
if (this.selectedProfiles.size > 0) {
this.el.appendChild(this._renderBulkActions());
}
// Favourites
const favourites = profiles.filter((p) => p.favourite);
@@ -178,6 +194,10 @@ class Sidebar {
dataset: { profileId: profile.id },
});
if (this.selectedProfiles.has(profile.id)) {
item.classList.add('sidebar-item-selected');
}
// Highlight if this profile's session is active
const activeSession = this.app.state.activeSessionId
? this.app.state.getSession(this.app.state.activeSessionId)
@@ -227,8 +247,21 @@ class Sidebar {
item.appendChild(star);
}
// Click → connect
item.addEventListener('click', () => {
// Prevent mousedown text selection in WKWebView
item.addEventListener('mousedown', (e) => e.preventDefault());
// Click → connect / multi-select
item.addEventListener('click', (e) => {
const multi = e.metaKey || e.ctrlKey;
if (multi) {
e.preventDefault();
this._toggleSelection(profile.id);
return;
}
if (this.selectedProfiles.size > 0) {
this._clearSelection();
}
this.app.connectProfile(profile.id);
});
@@ -406,6 +439,285 @@ class Sidebar {
});
}
// ── Multi-select + bulk actions ─────────────────────────────────
/**
* Removes selections that are no longer visible in the filtered list.
* @param {Array<Object>} profiles
* @private
*/
_syncSelection(profiles) {
const valid = new Set(profiles.map((p) => p.id));
for (const id of [...this.selectedProfiles]) {
if (!valid.has(id)) this.selectedProfiles.delete(id);
}
}
/** @private */
_toggleSelection(profileId) {
if (this.selectedProfiles.has(profileId)) {
this.selectedProfiles.delete(profileId);
} else {
this.selectedProfiles.add(profileId);
}
this.render();
}
/** @private */
_clearSelection() {
if (this.selectedProfiles.size === 0) return;
this.selectedProfiles.clear();
this.render();
}
/** @private */
_getSelectedProfiles() {
return this.app.state.profiles.filter((p) => this.selectedProfiles.has(p.id));
}
/** @private */
_renderBulkActions() {
const bar = createElement('div', 'sidebar-bulk-actions');
const count = createElement('span', 'sidebar-bulk-count', {
textContent: `${this.selectedProfiles.size} selected`,
});
bar.appendChild(count);
const actions = createElement('div', 'sidebar-bulk-buttons');
const makeBtn = (label, handler, className) => {
const btn = createElement('button', `btn btn-xs ${className || ''}`.trim(), {
textContent: label,
});
btn.addEventListener('click', handler);
actions.appendChild(btn);
};
makeBtn('Connect', () => this._bulkConnect());
makeBtn('Disconnect', () => this._bulkDisconnect(), 'btn-outline');
makeBtn('Tags', () => this._bulkEditTags(), 'btn-outline');
makeBtn('Move Group', () => this._bulkMoveGroup(), 'btn-outline');
makeBtn('Clear', () => this._clearSelection(), 'btn-ghost');
bar.appendChild(actions);
return bar;
}
/** @private */
_bulkConnect() {
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
this.app.connectProfile(p.id);
}
this._clearSelection();
}
/** @private */
async _bulkDisconnect() {
const sessions = this.app.state.getAllSessions()
.filter((s) => this.selectedProfiles.has(s.profileId));
if (sessions.length === 0) {
this.app.notifications.info('No active sessions to disconnect');
return;
}
const ok = await this._confirmBulkDisconnect(sessions.length);
if (!ok) return;
for (const s of sessions) {
await this.app.closeSessionImmediate(s.id);
}
this._clearSelection();
}
/** @private */
async _bulkEditTags() {
const result = await this._promptTagEdit();
if (!result) return;
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
const updated = JSON.parse(JSON.stringify(p));
const existing = updated.tags || [];
if (result.mode === 'replace') {
updated.tags = result.tags;
} else {
const merged = new Set([...existing, ...result.tags]);
updated.tags = [...merged];
}
try {
const saved = await window.go.api.Service.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error('Failed to update tags: ' + err);
}
}
this.render();
this._clearSelection();
}
/** @private */
async _bulkMoveGroup() {
const folder = await this._promptGroupMove();
if (folder === null) return;
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
const updated = JSON.parse(JSON.stringify(p));
updated.folder = folder;
try {
const saved = await window.go.api.Service.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error('Failed to move group: ' + err);
}
}
this.render();
this._clearSelection();
}
/** @private */
_confirmBulkDisconnect(count) {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Disconnect Sessions</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Disconnect ${count} active ${count === 1 ? 'session' : 'sessions'}?
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</button>
</div>
`;
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); });
});
}
/** @private */
_promptTagEdit() {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Edit Tags</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Tags (comma separated)</label>
<input class="form-input tag-input" type="text" placeholder="prod, linux, db" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-outline confirm-add">Add Tags</button>
<button class="btn btn-sm btn-primary confirm-replace">Replace Tags</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
});
const input = modal.querySelector('.tag-input');
const parseTags = () => {
const raw = (input.value || '').split(',');
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
};
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(null));
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
modal.querySelector('.confirm-add').addEventListener('click', () => {
close({ mode: 'add', tags: parseTags() });
});
modal.querySelector('.confirm-replace').addEventListener('click', () => {
close({ mode: 'replace', tags: parseTags() });
});
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
setTimeout(() => input.focus(), 50);
});
}
/** @private */
_promptGroupMove() {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Move to Group</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Group name</label>
<input class="form-input group-input" type="text" placeholder="e.g. Production" />
<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:6px;">
Leave blank to remove the group.
</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-primary confirm-ok">Move</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
});
const input = modal.querySelector('.group-input');
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(null));
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
modal.querySelector('.confirm-ok').addEventListener('click', () => close(input.value.trim()));
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
setTimeout(() => input.focus(), 50);
});
}
// ── Filtering ────────────────────────────────────────────────────
/**
+356 -321
View File
@@ -1,24 +1,24 @@
(function() {
/**
* Nexus Terminal — xterm.js terminal manager.
* Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions.
* Handles data routing, resizing, and search.
*/
(function () {
/**
* Nexus Terminal — xterm.js terminal manager.
* Creates, manages, and destroys xterm.js instances for SSH and Telnet sessions.
* Handles data routing, resizing, and search.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */
class TerminalManager {
class TerminalManager {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.app = app;
/**
* Map of sessionId → { term, fitAddon, searchAddon, observer }.
* @type {Map<string, Object>}
*/
this.terminals = new Map();
/**
* Map of sessionId → { term, fitAddon, searchAddon, observer }.
* @type {Map<string, Object>}
*/
this.terminals = new Map();
}
// ── Public API ───────────────────────────────────────────────────
@@ -33,171 +33,190 @@ 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: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: profile.terminalFontSize || 14,
scrollback: profile.terminalScrollback || 10000,
cursorStyle: profile.terminalCursorStyle || 'block',
cursorBlink: true,
allowProposedApi: true,
convertEol: false,
windowsMode: false,
const themeName =
(this.app.state.preferences || {}).terminalTheme || "charcoal";
const term = new Terminal({
theme: window.NexusTerminal.getTerminalTheme(themeName),
fontFamily:
profile.terminalFontFamily ||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: profile.terminalFontSize || 14,
scrollback: profile.terminalScrollback || 10000,
cursorStyle: profile.terminalCursorStyle || "block",
cursorBlink: true,
allowProposedApi: true,
convertEol: false,
windowsMode: false,
});
// Load addons
const fitAddon = new FitAddon.FitAddon();
const searchAddon = new SearchAddon.SearchAddon();
let webLinksAddon = null;
let unicode11Addon = null;
term.loadAddon(fitAddon);
term.loadAddon(searchAddon);
try {
unicode11Addon = new Unicode11Addon.Unicode11Addon();
term.loadAddon(unicode11Addon);
term.unicode.activeVersion = "11";
} catch (_) {
// Unicode 11 addon is optional; fall back to default unicode handling.
}
// WebLinksAddon may not always be available
try {
webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
});
term.loadAddon(webLinksAddon);
} catch (_) {
// Not critical — links just won't be clickable
}
// Load addons
const fitAddon = new FitAddon.FitAddon();
const searchAddon = new SearchAddon.SearchAddon();
let webLinksAddon = null;
let unicode11Addon = null;
// Open the terminal in the DOM container
const container = document.getElementById(containerId);
term.open(container);
term.loadAddon(fitAddon);
term.loadAddon(searchAddon);
let suppressPasteEvent = false;
const attachPasteGuard = () => {
const inputEl = term.textarea || container.querySelector("textarea");
if (!inputEl) return;
inputEl.addEventListener("paste", (e) => {
if (!suppressPasteEvent) return;
e.preventDefault();
e.stopPropagation();
suppressPasteEvent = false;
});
};
attachPasteGuard();
// Right-click context menu on the terminal viewport
const termEl = container.querySelector(".xterm");
if (termEl) {
termEl.addEventListener("contextmenu", (e) => {
e.preventDefault();
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
});
}
// Force a synchronous layout reflow so the container reports its
// true dimensions, then fit the terminal to fill the space.
// eslint-disable-next-line no-unused-expressions
container.offsetHeight;
const doFit = () => {
try {
unicode11Addon = new Unicode11Addon.Unicode11Addon();
term.loadAddon(unicode11Addon);
term.unicode.activeVersion = '11';
fitAddon.fit();
} catch (_) {
// Unicode 11 addon is optional; fall back to default unicode handling.
/* noop */
}
};
doFit();
// Belt-and-braces: retry once after a paint in case the initial
// reflow dimensions were still stale.
requestAnimationFrame(doFit);
// WebLinksAddon may not always be available
// Forward user keystrokes to the backend
term.onData((data) => {
try {
webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
});
term.loadAddon(webLinksAddon);
const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64);
// Broadcast to all other connected sessions if enabled
if (this.app.state.broadcastMode) {
for (const [otherId] of this.terminals) {
if (otherId === sessionId) continue;
const otherSession = this.app.state.getSession(otherId);
if (otherSession && otherSession.state === "connected") {
try {
window.go.api.Service.WriteSession(otherId, b64);
} catch (_) {
/* non-critical */
}
}
}
}
} catch (err) {
console.error("WriteSession error:", err);
}
});
// Clipboard: copy selection on Cmd/Ctrl+C, paste on Cmd/Ctrl+V
term.attachCustomKeyEventHandler((e) => {
const mod = e.ctrlKey || e.metaKey;
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
if (mod && e.key === "c" && e.type === "keydown") {
const selection = term.getSelection();
if (selection) {
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(selection);
} else {
navigator.clipboard.writeText(selection).catch(function () {});
}
return false; // Prevent xterm from sending Ctrl+C
}
// No selection — let Ctrl+C pass through as SIGINT
return true;
}
// Cmd/Ctrl+V — paste from clipboard
if (mod && e.key === "v" && e.type === "keydown") {
suppressPasteEvent = true;
setTimeout(() => {
suppressPasteEvent = false;
}, 250);
if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime
.ClipboardGetText()
.then(function (text) {
if (text) term.paste(text);
})
.catch(function () {});
} else {
navigator.clipboard
.readText()
.then(function (text) {
if (text) term.paste(text);
})
.catch(function () {});
}
return false;
}
return true;
});
// Notify backend when terminal dimensions change
term.onResize(({ cols, rows }) => {
try {
window.go.api.Service.ResizeSession(sessionId, cols, rows);
} catch (err) {
console.error("ResizeSession error:", err);
}
});
// Re-fit when the container itself resizes
const observer = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch (_) {
// Not critical — links just won't be clickable
// Ignore transient errors during layout shifts
}
});
observer.observe(container);
// Open the terminal in the DOM container
const container = document.getElementById(containerId);
term.open(container);
this.terminals.set(sessionId, {
term,
fitAddon,
searchAddon,
webLinksAddon,
observer,
});
let suppressPasteEvent = false;
const attachPasteGuard = () => {
const inputEl = term.textarea || container.querySelector('textarea');
if (!inputEl) return;
inputEl.addEventListener('paste', (e) => {
if (!suppressPasteEvent) return;
e.preventDefault();
e.stopPropagation();
suppressPasteEvent = false;
});
};
attachPasteGuard();
// Right-click context menu on the terminal viewport
const termEl = container.querySelector('.xterm');
if (termEl) {
termEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
});
}
// Force a synchronous layout reflow so the container reports its
// true dimensions, then fit the terminal to fill the space.
// eslint-disable-next-line no-unused-expressions
container.offsetHeight;
const doFit = () => { try { fitAddon.fit(); } catch (_) { /* noop */ } };
doFit();
// Belt-and-braces: retry once after a paint in case the initial
// reflow dimensions were still stale.
requestAnimationFrame(doFit);
// Forward user keystrokes to the backend
term.onData((data) => {
try {
const b64 = btoa(data);
window.go.api.Service.WriteSession(sessionId, b64);
// Broadcast to all other connected sessions if enabled
if (this.app.state.broadcastMode) {
for (const [otherId] of this.terminals) {
if (otherId === sessionId) continue;
const otherSession = this.app.state.getSession(otherId);
if (otherSession && otherSession.state === 'connected') {
try {
window.go.api.Service.WriteSession(otherId, b64);
} catch (_) { /* non-critical */ }
}
}
}
} catch (err) {
console.error('WriteSession error:', err);
}
});
// Clipboard: copy selection on Cmd/Ctrl+C, paste on Cmd/Ctrl+V
term.attachCustomKeyEventHandler((e) => {
const mod = e.ctrlKey || e.metaKey;
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
if (mod && e.key === 'c' && e.type === 'keydown') {
const selection = term.getSelection();
if (selection) {
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(selection);
} else {
navigator.clipboard.writeText(selection).catch(function() {});
}
return false; // Prevent xterm from sending Ctrl+C
}
// No selection — let Ctrl+C pass through as SIGINT
return true;
}
// Cmd/Ctrl+V — paste from clipboard
if (mod && e.key === 'v' && e.type === 'keydown') {
suppressPasteEvent = true;
setTimeout(() => { suppressPasteEvent = false; }, 250);
if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime.ClipboardGetText().then(function(text) {
if (text) term.paste(text);
}).catch(function() {});
} else {
navigator.clipboard.readText().then(function(text) {
if (text) term.paste(text);
}).catch(function() {});
}
return false;
}
return true;
});
// Notify backend when terminal dimensions change
term.onResize(({ cols, rows }) => {
try {
window.go.api.Service.ResizeSession(sessionId, cols, rows);
} catch (err) {
console.error('ResizeSession error:', err);
}
});
// Re-fit when the container itself resizes
const observer = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch (_) {
// Ignore transient errors during layout shifts
}
});
observer.observe(container);
this.terminals.set(sessionId, {
term,
fitAddon,
searchAddon,
webLinksAddon,
observer,
});
return term;
return term;
}
/**
@@ -206,15 +225,15 @@ class TerminalManager {
* @param {string} base64Data
*/
handleData(sessionId, base64Data) {
const entry = this.terminals.get(sessionId);
if (!entry) return;
const entry = this.terminals.get(sessionId);
if (!entry) return;
try {
const raw = atob(base64Data);
entry.term.write(raw);
} catch (err) {
console.error('Failed to decode session data:', err);
}
try {
const raw = atob(base64Data);
entry.term.write(raw);
} catch (err) {
console.error("Failed to decode session data:", err);
}
}
/**
@@ -222,12 +241,12 @@ class TerminalManager {
* @param {string} sessionId
*/
destroyTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (!entry) return;
const entry = this.terminals.get(sessionId);
if (!entry) return;
entry.observer.disconnect();
entry.term.dispose();
this.terminals.delete(sessionId);
entry.observer.disconnect();
entry.term.dispose();
this.terminals.delete(sessionId);
}
/**
@@ -235,28 +254,32 @@ class TerminalManager {
* @param {string} sessionId
*/
showTerminal(sessionId) {
for (const [id, entry] of this.terminals) {
const panel = document.getElementById(`panel-${id}`);
if (panel) {
panel.style.display = id === sessionId ? 'flex' : 'none';
}
for (const [id, entry] of this.terminals) {
const panel = document.getElementById(`panel-${id}`);
if (panel) {
panel.style.display = id === sessionId ? "flex" : "none";
}
}
// Re-fit the newly visible terminal
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
});
}
// Re-fit the newly visible terminal
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try {
entry.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
}
/**
* Re-fits the currently active terminal to its container.
*/
fitActive() {
const id = this.app.state.activeSessionId;
if (id) this.fitSession(id);
const id = this.app.state.activeSessionId;
if (id) this.fitSession(id);
}
/**
@@ -264,12 +287,16 @@ class TerminalManager {
* @param {string} sessionId
*/
fitSession(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
});
}
const entry = this.terminals.get(sessionId);
if (entry) {
requestAnimationFrame(() => {
try {
entry.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
}
/**
@@ -283,25 +310,25 @@ class TerminalManager {
* @returns {boolean} Whether a match was found.
*/
search(sessionId, query, options = {}) {
const entry = this.terminals.get(sessionId);
if (!entry) return false;
const entry = this.terminals.get(sessionId);
if (!entry) return false;
if (!query) {
entry.searchAddon.clearDecorations();
return false;
}
if (!query) {
entry.searchAddon.clearDecorations();
return false;
}
return entry.searchAddon.findNext(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
decorations: {
matchBackground: '#ffa72633',
activeMatchBackground: '#4ecdc466',
matchBorder: '#ffa726',
activeMatchBorder: '#4ecdc4',
},
});
return entry.searchAddon.findNext(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
decorations: {
matchBackground: "#ffa72633",
activeMatchBackground: "#4ecdc466",
matchBorder: "#ffa726",
activeMatchBorder: "#4ecdc4",
},
});
}
/**
@@ -312,13 +339,13 @@ class TerminalManager {
* @returns {boolean}
*/
searchPrevious(sessionId, query, options = {}) {
const entry = this.terminals.get(sessionId);
if (!entry || !query) return false;
return entry.searchAddon.findPrevious(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
});
const entry = this.terminals.get(sessionId);
if (!entry || !query) return false;
return entry.searchAddon.findPrevious(query, {
regex: options.regex || false,
caseSensitive: options.caseSensitive || false,
wholeWord: options.wholeWord || false,
});
}
/**
@@ -326,8 +353,8 @@ class TerminalManager {
* @param {string} sessionId
*/
clearTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.clear();
const entry = this.terminals.get(sessionId);
if (entry) entry.term.clear();
}
/**
@@ -335,8 +362,8 @@ class TerminalManager {
* @param {string} sessionId
*/
resetTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.reset();
const entry = this.terminals.get(sessionId);
if (entry) entry.term.reset();
}
/**
@@ -344,8 +371,8 @@ class TerminalManager {
* @param {string} sessionId
*/
focusTerminal(sessionId) {
const entry = this.terminals.get(sessionId);
if (entry) entry.term.focus();
const entry = this.terminals.get(sessionId);
if (entry) entry.term.focus();
}
/**
@@ -353,10 +380,10 @@ class TerminalManager {
* @param {string} themeName
*/
applyTheme(themeName) {
const themeObj = window.NexusTerminal.getTerminalTheme(themeName);
for (const [, entry] of this.terminals) {
entry.term.options.theme = themeObj;
}
const themeObj = window.NexusTerminal.getTerminalTheme(themeName);
for (const [, entry] of this.terminals) {
entry.term.options.theme = themeObj;
}
}
// ── Private helpers ──────────────────────────────────────────────
@@ -370,95 +397,103 @@ class TerminalManager {
* @private
*/
_showTerminalContextMenu(sessionId, term, x, y) {
const { createElement, dismissContextMenus } = window.NexusTerminal;
dismissContextMenus();
const { createElement, dismissContextMenus } = window.NexusTerminal;
dismissContextMenus();
const hasSelection = term.hasSelection();
const menu = createElement('div', 'context-menu');
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const hasSelection = term.hasSelection();
const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const items = [
{
label: 'Copy',
disabled: !hasSelection,
action: () => {
const text = term.getSelection();
if (text) {
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(text);
} else {
navigator.clipboard.writeText(text).catch(function() {});
}
this.app.notifications.info('Copied to clipboard');
}
},
},
{
label: 'Paste',
action: () => {
if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime.ClipboardGetText().then((text) => {
if (text) term.paste(text);
}).catch(function() {});
} else {
navigator.clipboard.readText().then((text) => {
if (text) term.paste(text);
}).catch(function() {});
}
},
},
{ divider: true },
{
label: 'Select All',
action: () => term.selectAll(),
},
{
label: 'Find…',
action: () => this.app._openTerminalSearch(),
},
{
label: 'Send Snippet…',
action: () => this.app.snippets.open(),
},
{ divider: true },
{
label: 'Clear Terminal',
action: () => term.clear(),
},
{
label: 'Reset Terminal',
action: () => term.reset(),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement('div', 'context-menu-divider'));
continue;
const items = [
{
label: "Copy",
disabled: !hasSelection,
action: () => {
const text = term.getSelection();
if (text) {
if (window.runtime && window.runtime.ClipboardSetText) {
window.runtime.ClipboardSetText(text);
} else {
navigator.clipboard.writeText(text).catch(function () {});
}
this.app.notifications.info("Copied to clipboard");
}
const btn = createElement('button', 'context-menu-item', {
textContent: entry.label,
});
if (entry.disabled) {
btn.disabled = true;
btn.classList.add('context-menu-disabled');
},
},
{
label: "Paste",
action: () => {
if (window.runtime && window.runtime.ClipboardGetText) {
window.runtime
.ClipboardGetText()
.then((text) => {
if (text) term.paste(text);
})
.catch(function () {});
} else {
navigator.clipboard
.readText()
.then((text) => {
if (text) term.paste(text);
})
.catch(function () {});
}
btn.addEventListener('click', () => {
dismissContextMenus();
entry.action();
});
// Prevent mousedown from causing text deselection in WKWebView
btn.addEventListener('mousedown', (e) => e.preventDefault());
menu.appendChild(btn);
},
},
{ divider: true },
{
label: "Select All",
action: () => term.selectAll(),
},
{
label: "Find…",
action: () => this.app._openTerminalSearch(),
},
{
label: "Send Snippet…",
action: () => this.app.snippets.open(),
},
{ divider: true },
{
label: "Clear Terminal",
action: () => term.clear(),
},
{
label: "Reset Terminal",
action: () => term.reset(),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement("div", "context-menu-divider"));
continue;
}
const btn = createElement("button", "context-menu-item", {
textContent: entry.label,
});
if (entry.disabled) {
btn.disabled = true;
btn.classList.add("context-menu-disabled");
}
btn.addEventListener("click", () => {
dismissContextMenus();
entry.action();
});
// Prevent mousedown from causing text deselection in WKWebView
btn.addEventListener("mousedown", (e) => e.preventDefault());
menu.appendChild(btn);
}
document.body.appendChild(menu);
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`;
// 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`;
}
/**
@@ -467,12 +502,12 @@ class TerminalManager {
* @private
*/
_buildTheme() {
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
return window.NexusTerminal.getTerminalTheme(themeName);
const themeName =
(this.app.state.preferences || {}).terminalTheme || "charcoal";
return window.NexusTerminal.getTerminalTheme(themeName);
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TerminalManager = TerminalManager;
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.TerminalManager = TerminalManager;
})();
+2
View File
@@ -33,6 +33,8 @@ export function DeleteTOTPSecret(arg1:string):Promise<void>;
export function DisconnectSession(arg1:string):Promise<void>;
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
export function ExportProfiles(arg1:boolean):Promise<string>;
+4
View File
@@ -62,6 +62,10 @@ export function DisconnectSession(arg1) {
return window['go']['api']['Service']['DisconnectSession'](arg1);
}
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
}
export function DuplicateProfile(arg1) {
return window['go']['api']['Service']['DuplicateProfile'](arg1);
}
+6
View File
@@ -95,6 +95,9 @@ export namespace models {
owner: string;
description: string;
htmlURL: string;
defaultBranch: string;
latestCommitter: string;
latestCommitAt: string;
private: boolean;
fork: boolean;
archived: boolean;
@@ -114,6 +117,9 @@ export namespace models {
this.owner = source["owner"];
this.description = source["description"];
this.htmlURL = source["htmlURL"];
this.defaultBranch = source["defaultBranch"];
this.latestCommitter = source["latestCommitter"];
this.latestCommitAt = source["latestCommitAt"];
this.private = source["private"];
this.fork = source["fork"];
this.archived = source["archived"];