UI Updates, and fix UTF8 issues

This commit is contained in:
Andy Dixon
2026-04-21 08:59:49 +01:00
parent c5debd9c77
commit 7146063da2
8 changed files with 3629 additions and 2795 deletions
+4 -1
View File
@@ -215,7 +215,10 @@
font-size: 10px;
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.github-repo-download:hover,
+34 -12
View File
@@ -104,7 +104,9 @@
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
transition:
color var(--transition-fast),
background var(--transition-fast);
-webkit-user-select: none;
user-select: none;
text-align: left;
@@ -209,7 +211,7 @@
background: var(--accent-muted);
}
.sidebar-item.active::before {
content: '';
content: "";
position: absolute;
left: 0;
top: 4px;
@@ -236,13 +238,21 @@
}
.sidebar-item .protocol-indicator::before {
content: '?';
content: "?";
}
.sidebar-item .protocol-indicator.proto-ssh::before { content: '⌨'; }
.sidebar-item .protocol-indicator.proto-telnet::before { content: '⇄'; }
.sidebar-item .protocol-indicator.proto-raw::before { content: '≋'; }
.sidebar-item .protocol-indicator.proto-github::before { content: 'GH'; }
.sidebar-item .protocol-indicator.proto-ssh::before {
content: "⌨";
}
.sidebar-item .protocol-indicator.proto-telnet::before {
content: "⇄";
}
.sidebar-item .protocol-indicator.proto-raw::before {
content: "≋";
}
.sidebar-item .protocol-indicator.proto-github::before {
content: "GH";
}
.sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18);
@@ -303,7 +313,10 @@
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
transition:
opacity var(--transition-fast),
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-item:hover .sidebar-item-edit {
@@ -327,7 +340,9 @@
color: var(--text-disabled);
border-radius: var(--radius-sm);
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast);
transition:
opacity var(--transition-fast),
color var(--transition-fast);
}
.sidebar-item:hover .sidebar-item-star {
@@ -351,9 +366,16 @@
flex-shrink: 0;
margin-right: var(--space-1);
}
.sidebar-item-status.is-connected { background: var(--success); }
.sidebar-item-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
.sidebar-item-status.is-error { background: var(--error); }
.sidebar-item-status.is-connected {
background: var(--success);
}
.sidebar-item-status.is-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.sidebar-item-status.is-error {
background: var(--error);
}
/* -----------------------------------------------------------------------
Sidebar empty list placeholder
+20 -10
View File
@@ -918,10 +918,7 @@
"permission denied",
"no supported methods",
];
const hostKeyPatterns = [
"host key",
"fingerprint",
];
const hostKeyPatterns = ["host key", "fingerprint"];
if (authPatterns.some((p) => msg.includes(p))) {
return {
@@ -959,7 +956,11 @@
const session = this.state.getSession(sessionId);
const label = session?.tabName || session?.host || "Session";
const body = `${info.message}\n\n${message}`;
this._showGuidedErrorModal(info.title, label, body, (action) => {
this._showGuidedErrorModal(
info.title,
label,
body,
(action) => {
if (action === "edit" && session?.profileId) {
this.profileEditor.openEdit(session.profileId);
}
@@ -969,7 +970,9 @@
if (action === "knownhosts") {
this.settings.openTab("knownhosts");
}
}, info.actions);
},
info.actions,
);
}
/**
@@ -1005,15 +1008,20 @@
const footer = modal.querySelector(".modal-footer");
const addActionBtn = (label, action, primary) => {
const btn = createElement("button", `btn btn-sm${primary ? " btn-primary" : ""}`, {
const btn = createElement(
"button",
`btn btn-sm${primary ? " btn-primary" : ""}`,
{
textContent: label,
});
},
);
btn.addEventListener("click", () => close(action));
footer.appendChild(btn);
};
addActionBtn("Close", null, false);
if (actions.includes("knownhosts")) addActionBtn("Known Hosts", "knownhosts", false);
if (actions.includes("knownhosts"))
addActionBtn("Known Hosts", "knownhosts", false);
if (actions.includes("edit")) addActionBtn("Edit Profile", "edit", false);
if (actions.includes("retry")) addActionBtn("Retry", "retry", true);
@@ -1024,7 +1032,9 @@
if (action) onAction(action);
};
modal.querySelector(".modal-close").addEventListener("click", () => close(null));
modal
.querySelector(".modal-close")
.addEventListener("click", () => close(null));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null);
});
+129 -97
View File
@@ -23,7 +23,11 @@ class GitHubViewer {
createViewer(sessionID, container, profileName) {
if (this._viewers.has(sessionID)) return;
const viewer = new GitHubViewerInstance(sessionID, container, profileName);
const viewer = new GitHubViewerInstance(
sessionID,
container,
profileName,
);
this._viewers.set(sessionID, viewer);
viewer.render();
}
@@ -90,13 +94,13 @@ class GitHubViewerInstance {
this.profileName = profileName;
/** @type {string} */
this._login = '';
this._login = "";
/** @type {Array} */
this._repos = [];
/** @type {string} */
this._filter = '';
this._filter = "";
/** @type {HTMLElement|null} */
this._el = null;
@@ -112,30 +116,30 @@ class GitHubViewerInstance {
}
render() {
this.container.innerHTML = '';
this.container.innerHTML = "";
const wrapper = createElement('div', 'github-viewer');
const wrapper = createElement("div", "github-viewer");
// ── Header bar ──────────────────────────────────────────
const header = createElement('div', 'github-viewer-header');
const header = createElement("div", "github-viewer-header");
const titleArea = createElement('div', 'github-viewer-title-area');
const icon = createElement('span', 'github-viewer-icon');
const titleArea = createElement("div", "github-viewer-title-area");
const icon = createElement("span", "github-viewer-icon");
icon.innerHTML = this._githubSVG();
titleArea.appendChild(icon);
const titleText = createElement('span', 'github-viewer-title');
titleText.textContent = this.profileName || 'GitHub';
const titleText = createElement("span", "github-viewer-title");
titleText.textContent = this.profileName || "GitHub";
titleArea.appendChild(titleText);
header.appendChild(titleArea);
// Search / filter
const searchWrap = createElement('div', 'github-viewer-search');
const searchInput = createElement('input', 'github-viewer-search-input', {
type: 'text',
placeholder: 'Filter repositories…',
const searchWrap = createElement("div", "github-viewer-search");
const searchInput = createElement("input", "github-viewer-search-input", {
type: "text",
placeholder: "Filter repositories…",
});
searchInput.addEventListener('input', (e) => {
searchInput.addEventListener("input", (e) => {
this._filter = e.target.value.toLowerCase();
this._renderList();
});
@@ -143,22 +147,22 @@ class GitHubViewerInstance {
header.appendChild(searchWrap);
// Refresh button
const refreshBtn = createElement('button', 'github-viewer-refresh', {
title: 'Refresh now',
const refreshBtn = createElement("button", "github-viewer-refresh", {
title: "Refresh now",
});
refreshBtn.innerHTML = '↻';
refreshBtn.addEventListener('click', () => this._manualRefresh());
refreshBtn.innerHTML = "↻";
refreshBtn.addEventListener("click", () => this._manualRefresh());
header.appendChild(refreshBtn);
wrapper.appendChild(header);
// ── Status bar ──────────────────────────────────────────
this._statusEl = createElement('div', 'github-viewer-status');
this._statusEl.textContent = 'Connecting…';
this._statusEl = createElement("div", "github-viewer-status");
this._statusEl.textContent = "Connecting…";
wrapper.appendChild(this._statusEl);
// ── Repo list ───────────────────────────────────────────
this._listEl = createElement('div', 'github-viewer-list');
this._listEl = createElement("div", "github-viewer-list");
wrapper.appendChild(this._listEl);
this.container.appendChild(wrapper);
@@ -183,8 +187,8 @@ class GitHubViewerInstance {
*/
setStatus(message) {
if (!this._statusEl) return;
this._statusEl.classList.remove('github-viewer-status-error');
this._statusEl.textContent = message || '';
this._statusEl.classList.remove("github-viewer-status-error");
this._statusEl.textContent = message || "";
}
/**
@@ -194,7 +198,7 @@ class GitHubViewerInstance {
showError(message) {
if (this._statusEl) {
this._statusEl.textContent = `⚠ ${message}`;
this._statusEl.classList.add('github-viewer-status-error');
this._statusEl.classList.add("github-viewer-status-error");
}
}
@@ -213,44 +217,63 @@ class GitHubViewerInstance {
_updateStatus() {
if (!this._statusEl) return;
this._statusEl.classList.remove('github-viewer-status-error');
this._statusEl.classList.remove("github-viewer-status-error");
const count = this._repos.length;
const now = new Date().toLocaleTimeString();
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? 'y' : 'ies'} · updated ${now}`;
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`;
}
_renderList() {
if (!this._listEl) return;
this._listEl.innerHTML = '';
this._listEl.innerHTML = "";
// Filter
let filtered = this._repos;
if (this._filter) {
filtered = this._repos.filter((r) =>
filtered = this._repos.filter(
(r) =>
r.fullName.toLowerCase().includes(this._filter) ||
(r.description || '').toLowerCase().includes(this._filter) ||
(r.language || '').toLowerCase().includes(this._filter)
(r.description || "").toLowerCase().includes(this._filter) ||
(r.language || "").toLowerCase().includes(this._filter),
);
}
if (filtered.length === 0) {
const empty = createElement('div', 'github-viewer-empty');
empty.textContent = this._filter ? 'No matching repositories.' : 'No repositories found.';
const empty = createElement("div", "github-viewer-empty");
empty.textContent = this._filter
? "No matching repositories."
: "No repositories found.";
this._listEl.appendChild(empty);
return;
}
const commitTimestamp = (repo) => {
const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || "";
const ts = new Date(raw).getTime();
return Number.isNaN(ts) ? 0 : ts;
};
// Group by owner
const groups = new Map();
for (const repo of filtered) {
const owner = repo.owner || 'unknown';
const owner = repo.owner || "unknown";
if (!groups.has(owner)) groups.set(owner, []);
groups.get(owner).push(repo);
}
// Sort groups alphabetically (user's own repos first)
// Sort repos within each group by latest commit (newest first)
for (const repos of groups.values()) {
repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a));
}
// Sort groups by most recent commit in each group (login first on ties)
const sortedKeys = [...groups.keys()].sort((a, b) => {
const aRepos = groups.get(a) || [];
const bRepos = groups.get(b) || [];
const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0;
const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0;
if (aMax !== bMax) return bMax - aMax;
if (a === this._login) return -1;
if (b === this._login) return 1;
return a.localeCompare(b);
@@ -260,8 +283,8 @@ class GitHubViewerInstance {
const repos = groups.get(owner);
// Group header
const groupEl = createElement('div', 'github-group');
const groupHeader = createElement('div', 'github-group-header');
const groupEl = createElement("div", "github-group");
const groupHeader = createElement("div", "github-group-header");
groupHeader.innerHTML = `
<span class="github-group-name">${escapeHtml(owner)}</span>
<span class="github-group-count">${repos.length}</span>
@@ -283,57 +306,65 @@ class GitHubViewerInstance {
* @returns {HTMLElement}
*/
_renderRepoCard(repo) {
const card = createElement('div', 'github-repo-card');
card.addEventListener('click', () => {
if (repo.htmlURL) window.open(repo.htmlURL, '_blank');
const card = createElement("div", "github-repo-card");
card.addEventListener("click", () => {
if (repo.htmlURL) window.open(repo.htmlURL, "_blank");
});
// Top row: name + badges
const topRow = createElement('div', 'github-repo-top');
const nameEl = createElement('span', 'github-repo-name');
const topRow = createElement("div", "github-repo-top");
const nameEl = createElement("span", "github-repo-name");
nameEl.textContent = repo.name;
topRow.appendChild(nameEl);
if (repo.private) {
const badge = createElement('span', 'github-repo-badge github-repo-private');
badge.textContent = 'Private';
const badge = createElement(
"span",
"github-repo-badge github-repo-private",
);
badge.textContent = "Private";
topRow.appendChild(badge);
}
if (repo.fork) {
const badge = createElement('span', 'github-repo-badge github-repo-fork');
badge.textContent = 'Fork';
const badge = createElement(
"span",
"github-repo-badge github-repo-fork",
);
badge.textContent = "Fork";
topRow.appendChild(badge);
}
if (repo.archived) {
const badge = createElement('span', 'github-repo-badge github-repo-archived');
badge.textContent = 'Archived';
const badge = createElement(
"span",
"github-repo-badge github-repo-archived",
);
badge.textContent = "Archived";
topRow.appendChild(badge);
}
const actions = createElement('div', 'github-repo-actions');
const actions = createElement("div", "github-repo-actions");
const downloadBtn = createElement('button', 'github-repo-download', {
textContent: 'ZIP',
title: 'Download repository as zip',
const downloadBtn = createElement("button", "github-repo-download", {
textContent: "ZIP",
title: "Download repository as zip",
});
downloadBtn.addEventListener('click', (e) => {
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',
const visitLink = createElement("button", "github-repo-visit", {
textContent: "Visit",
title: "Open repository in browser",
});
visitLink.addEventListener('click', (e) => {
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');
window.open(repo.htmlURL, "_blank");
}
}
});
@@ -344,37 +375,39 @@ class GitHubViewerInstance {
card.appendChild(topRow);
if (repo.latestCommitter && repo.latestCommitAt) {
const commitLine = createElement('div', 'github-repo-commit');
const commitLine = createElement("div", "github-repo-commit");
const when = new Date(repo.latestCommitAt);
const whenText = isNaN(when.getTime()) ? repo.latestCommitAt : when.toLocaleString();
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');
const desc = createElement("div", "github-repo-desc");
desc.textContent = repo.description;
card.appendChild(desc);
}
// Meta row: language, stars, pushed
const meta = createElement('div', 'github-repo-meta');
const meta = createElement("div", "github-repo-meta");
if (repo.language) {
const lang = createElement('span', 'github-repo-lang');
const lang = createElement("span", "github-repo-lang");
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
meta.appendChild(lang);
}
if (repo.stars > 0) {
const stars = createElement('span', 'github-repo-stars');
const stars = createElement("span", "github-repo-stars");
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
meta.appendChild(stars);
}
if (repo.pushedAt) {
const pushed = createElement('span', 'github-repo-pushed');
const pushed = createElement("span", "github-repo-pushed");
pushed.textContent = relativeTime(repo.pushedAt);
pushed.title = new Date(repo.pushedAt).toLocaleString();
meta.appendChild(pushed);
@@ -387,8 +420,8 @@ class GitHubViewerInstance {
async _downloadRepo(repo) {
if (!this._statusEl) return;
const ref = repo.defaultBranch || '';
this._statusEl.classList.remove('github-viewer-status-error');
const ref = repo.defaultBranch || "";
this._statusEl.classList.remove("github-viewer-status-error");
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
try {
@@ -400,17 +433,17 @@ class GitHubViewerInstance {
this._updateStatus();
} catch (err) {
this._statusEl.textContent = `⚠ Download failed: ${err}`;
this._statusEl.classList.add('github-viewer-status-error');
this._statusEl.classList.add("github-viewer-status-error");
}
}
async _manualRefresh() {
if (!this._statusEl) return;
this._statusEl.textContent = 'Refreshing…';
this._statusEl.textContent = "Refreshing…";
try {
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
} catch (err) {
this.showError(err.message || 'Refresh failed');
this.showError(err.message || "Refresh failed");
}
}
@@ -422,32 +455,32 @@ class GitHubViewerInstance {
*/
_langColour(lang) {
const colours = {
'JavaScript': '#f1e05a',
'TypeScript': '#3178c6',
'Python': '#3572A5',
'Go': '#00ADD8',
'Rust': '#dea584',
'Java': '#b07219',
'C': '#555555',
'C++': '#f34b7d',
'C#': '#178600',
'Ruby': '#701516',
'PHP': '#4F5D95',
'Swift': '#F05138',
'Kotlin': '#A97BFF',
'Shell': '#89e051',
'HTML': '#e34c26',
'CSS': '#563d7c',
'Dart': '#00B4AB',
'Lua': '#000080',
'Scala': '#c22d40',
'Elixir': '#6e4a7e',
'Haskell': '#5e5086',
'Zig': '#ec915c',
'Vue': '#41b883',
'SCSS': '#c6538c',
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Go: "#00ADD8",
Rust: "#dea584",
Java: "#b07219",
C: "#555555",
"C++": "#f34b7d",
"C#": "#178600",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
Dart: "#00B4AB",
Lua: "#000080",
Scala: "#c22d40",
Elixir: "#6e4a7e",
Haskell: "#5e5086",
Zig: "#ec915c",
Vue: "#41b883",
SCSS: "#c6538c",
};
return colours[lang] || '#8b949e';
return colours[lang] || "#8b949e";
}
/**
@@ -461,5 +494,4 @@ class GitHubViewerInstance {
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.GitHubViewer = new GitHubViewer();
})();
+1041 -558
View File
File diff suppressed because it is too large Load Diff
+377 -176
View File
@@ -5,16 +5,17 @@
* terminal defaults, security, known-hosts management, and about info.
*/
const { createElement, escapeHtml, formatTimestamp, $ } = window.NexusTerminal;
const { createElement, escapeHtml, formatTimestamp, $ } =
window.NexusTerminal;
/** Settings tab definitions. */
const TABS = [
{ key: 'general', label: 'General' },
{ key: 'terminal', label: 'Terminal' },
{ key: 'security', label: 'Security' },
{ key: 'knownhosts', label: 'Known Hosts' },
{ key: 'help', label: 'Help' },
{ key: 'about', label: 'About' },
{ key: "general", label: "General" },
{ key: "terminal", label: "Terminal" },
{ key: "security", label: "Security" },
{ key: "knownhosts", label: "Known Hosts" },
{ key: "help", label: "Help" },
{ key: "about", label: "About" },
];
class Settings {
@@ -28,7 +29,7 @@ class Settings {
this._overlay = null;
/** @type {string} Currently active tab key. */
this._activeTab = 'general';
this._activeTab = "general";
/** @type {Object} Working copy of preferences. */
this._prefs = {};
@@ -44,10 +45,10 @@ class Settings {
*/
async open(tabKey) {
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
this._activeTab = tabKey || this._activeTab || 'general';
this._activeTab = tabKey || this._activeTab || "general";
try {
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || [];
} catch (_) {
this._knownHosts = [];
}
@@ -60,7 +61,7 @@ class Settings {
* @param {string} tabKey
*/
async openTab(tabKey) {
this._activeTab = tabKey || 'general';
this._activeTab = tabKey || "general";
return this.open(this._activeTab);
}
@@ -88,14 +89,14 @@ class Settings {
if (this.app.terminals && this._prefs.terminalTheme) {
this.app.terminals.applyTheme(this._prefs.terminalTheme);
}
if (this.app && typeof this.app._applyPreferences === 'function') {
if (this.app && typeof this.app._applyPreferences === "function") {
this.app._applyPreferences();
}
this.app.notifications.success('Settings saved');
this.app.notifications.success("Settings saved");
this.close();
} catch (err) {
this.app.notifications.error('Failed to save settings: ' + err);
this.app.notifications.error("Failed to save settings: " + err);
}
}
@@ -105,30 +106,36 @@ class Settings {
_render() {
this.close();
const overlay = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-settings');
const overlay = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-settings");
// Header
const header = createElement('div', 'modal-header');
const header = createElement("div", "modal-header");
header.innerHTML = `
<h2 class="modal-title">Settings</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
header.querySelector('.modal-close').addEventListener('click', () => this.close());
header
.querySelector(".modal-close")
.addEventListener("click", () => this.close());
modal.appendChild(header);
// Tab bar
const tabBar = createElement('div', 'settings-tab-bar');
const tabBar = createElement("div", "settings-tab-bar");
for (const tab of TABS) {
const btn = createElement('button', `settings-tab ${tab.key === this._activeTab ? 'active' : ''}`, {
const btn = createElement(
"button",
`settings-tab ${tab.key === this._activeTab ? "active" : ""}`,
{
textContent: tab.label,
dataset: { tab: tab.key },
});
btn.addEventListener('click', () => {
},
);
btn.addEventListener("click", () => {
this._activeTab = tab.key;
this._renderBody(modal);
tabBar.querySelectorAll('.settings-tab').forEach((t) => {
t.classList.toggle('active', t.dataset.tab === tab.key);
tabBar.querySelectorAll(".settings-tab").forEach((t) => {
t.classList.toggle("active", t.dataset.tab === tab.key);
});
});
tabBar.appendChild(btn);
@@ -136,30 +143,36 @@ class Settings {
modal.appendChild(tabBar);
// Body (tab content)
const body = createElement('div', 'modal-body settings-body');
const body = createElement("div", "modal-body settings-body");
modal.appendChild(body);
this._renderTabContent(body);
// Footer
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' });
cancelBtn.addEventListener('click', () => this.close());
saveBtn.addEventListener('click', () => this.save());
const footer = createElement("div", "modal-footer");
const cancelBtn = createElement("button", "btn btn-secondary", {
textContent: "Cancel",
});
const saveBtn = createElement("button", "btn btn-primary", {
textContent: "Save",
});
cancelBtn.addEventListener("click", () => this.close());
saveBtn.addEventListener("click", () => this.save());
footer.appendChild(cancelBtn);
footer.appendChild(saveBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
modal.addEventListener('click', (e) => e.stopPropagation());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
modal.addEventListener("click", (e) => e.stopPropagation());
document.body.appendChild(overlay);
this._overlay = overlay;
// Trigger visibility after append so CSS transitions animate
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
overlay.classList.add("is-visible");
modal.classList.add("is-visible");
});
}
@@ -169,7 +182,7 @@ class Settings {
* @private
*/
_renderBody(modal) {
const body = modal.querySelector('.settings-body');
const body = modal.querySelector(".settings-body");
if (body) this._renderTabContent(body);
}
@@ -179,15 +192,27 @@ class Settings {
* @private
*/
_renderTabContent(body) {
body.innerHTML = '';
body.classList.remove('help-layout');
body.innerHTML = "";
body.classList.remove("help-layout");
switch (this._activeTab) {
case 'general': this._renderGeneralTab(body); break;
case 'terminal': this._renderTerminalTab(body); break;
case 'security': this._renderSecurityTab(body); break;
case 'knownhosts': this._renderKnownHostsTab(body); break;
case 'help': this._renderHelpTab(body); break;
case 'about': this._renderAboutTab(body); break;
case "general":
this._renderGeneralTab(body);
break;
case "terminal":
this._renderTerminalTab(body);
break;
case "security":
this._renderSecurityTab(body);
break;
case "knownhosts":
this._renderKnownHostsTab(body);
break;
case "help":
this._renderHelpTab(body);
break;
case "about":
this._renderAboutTab(body);
break;
}
}
@@ -200,23 +225,62 @@ class Settings {
*/
_renderGeneralTab(body) {
const p = this._prefs;
body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, {
body.appendChild(
this._settingField("select", "theme", "Theme", p.theme, {
options: [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
],
}));
body.appendChild(this._settingField('select', 'defaultProtocol', 'Default Protocol', p.defaultProtocol, {
}),
);
body.appendChild(
this._settingField(
"select",
"defaultProtocol",
"Default Protocol",
p.defaultProtocol,
{
options: [
{ value: 'ssh', label: 'SSH' },
{ value: 'telnet', label: 'Telnet' },
{ value: 'raw', label: 'RAW' },
{ value: "ssh", label: "SSH" },
{ value: "telnet", label: "Telnet" },
{ value: "raw", label: "RAW" },
],
}));
body.appendChild(this._settingField('toggle', 'confirmOnClose', 'Confirm on close', p.confirmOnClose));
body.appendChild(this._settingField('toggle', 'confirmOnDisconnect', 'Confirm on disconnect', p.confirmOnDisconnect));
body.appendChild(this._settingField('toggle', 'showStatusBar', 'Show status bar', p.showStatusBar));
body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 }));
},
),
);
body.appendChild(
this._settingField(
"toggle",
"confirmOnClose",
"Confirm on close",
p.confirmOnClose,
),
);
body.appendChild(
this._settingField(
"toggle",
"confirmOnDisconnect",
"Confirm on disconnect",
p.confirmOnDisconnect,
),
);
body.appendChild(
this._settingField(
"toggle",
"showStatusBar",
"Show status bar",
p.showStatusBar,
),
);
body.appendChild(
this._settingField(
"number",
"sidebarWidth",
"Sidebar width (px)",
p.sidebarWidth,
{ min: 180, max: 500 },
),
);
}
/**
@@ -229,22 +293,62 @@ class Settings {
// Terminal colour theme selector
const themes = window.NexusTerminal.listTerminalThemes();
body.appendChild(this._settingField('select', 'terminalTheme', 'Colour scheme', p.terminalTheme || 'charcoal', {
body.appendChild(
this._settingField(
"select",
"terminalTheme",
"Colour scheme",
p.terminalTheme || "charcoal",
{
options: themes,
}));
},
),
);
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
}));
body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 }));
body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 }));
body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, {
body.appendChild(
this._settingField(
"text",
"defaultFontFamily",
"Font family",
p.defaultFontFamily,
{
placeholder: "JetBrains Mono, Menlo, Monaco, Consolas, monospace",
},
),
);
body.appendChild(
this._settingField(
"number",
"defaultFontSize",
"Font size",
p.defaultFontSize,
{ min: 8, max: 36 },
),
);
body.appendChild(
this._settingField(
"number",
"defaultScrollback",
"Scrollback lines",
p.defaultScrollback,
{ min: 0, max: 100000 },
),
);
body.appendChild(
this._settingField(
"select",
"defaultCursorStyle",
"Cursor style",
p.defaultCursorStyle,
{
options: [
{ value: 'block', label: 'Block' },
{ value: 'underline', label: 'Underline' },
{ value: 'bar', label: 'Bar' },
{ value: "block", label: "Block" },
{ value: "underline", label: "Underline" },
{ value: "bar", label: "Bar" },
],
}));
},
),
);
}
/**
@@ -253,59 +357,67 @@ class Settings {
* @private
*/
_renderSecurityTab(body) {
const storeState = this.app.state.secretStoreUnlocked ? 'Unlocked' : 'Locked';
const storeState = this.app.state.secretStoreUnlocked
? "Unlocked"
: "Locked";
const storeInit = this.app.state.secretStoreInitialised;
const statusRow = createElement('div', 'settings-row');
const statusRow = createElement("div", "settings-row");
statusRow.innerHTML = `
<span class="settings-label">Secret store</span>
<span class="settings-value ${this.app.state.secretStoreUnlocked ? 'text-success' : 'text-muted'}">${storeState}</span>
<span class="settings-value ${this.app.state.secretStoreUnlocked ? "text-success" : "text-muted"}">${storeState}</span>
`;
body.appendChild(statusRow);
if (!storeInit) {
const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' });
initBtn.addEventListener('click', async () => {
const initBtn = createElement("button", "btn btn-primary btn-sm", {
textContent: "Initialise Secret Store",
});
initBtn.addEventListener("click", async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(true);
await window.go.api.Service.InitialiseSecretStore(pw);
this.app.state.setSecretStoreInitialised(true);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store created');
this.app.notifications.success("Secret store created");
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Failed to initialise: ' + err);
if (err.message !== "User cancelled") {
this.app.notifications.error("Failed to initialise: " + err);
}
}
});
body.appendChild(initBtn);
} else if (!this.app.state.secretStoreUnlocked) {
const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' });
unlockBtn.addEventListener('click', async () => {
const unlockBtn = createElement("button", "btn btn-primary btn-sm", {
textContent: "Unlock",
});
unlockBtn.addEventListener("click", async () => {
try {
const pw = await this.app.secretPrompt.promptMasterPassword(false);
await window.go.api.Service.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success('Secret store unlocked');
this.app.notifications.success("Secret store unlocked");
this._renderTabContent(body);
} catch (err) {
if (err.message !== 'User cancelled') {
this.app.notifications.error('Unlock failed: ' + err);
if (err.message !== "User cancelled") {
this.app.notifications.error("Unlock failed: " + err);
}
}
});
body.appendChild(unlockBtn);
} else {
const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' });
lockBtn.addEventListener('click', async () => {
const lockBtn = createElement("button", "btn btn-outline btn-sm", {
textContent: "Lock",
});
lockBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info('Secret store locked');
this.app.notifications.info("Secret store locked");
this._renderTabContent(body);
} catch (err) {
this.app.notifications.error('Lock failed: ' + err);
this.app.notifications.error("Lock failed: " + err);
}
});
body.appendChild(lockBtn);
@@ -319,11 +431,15 @@ class Settings {
*/
_renderKnownHostsTab(body) {
if (this._knownHosts.length === 0) {
body.appendChild(createElement('p', 'text-muted', { textContent: 'No known hosts recorded.' }));
body.appendChild(
createElement("p", "text-muted", {
textContent: "No known hosts recorded.",
}),
);
return;
}
const table = createElement('table', 'settings-table');
const table = createElement("table", "settings-table");
table.innerHTML = `
<thead>
<tr>
@@ -336,10 +452,10 @@ class Settings {
</tr>
</thead>
`;
const tbody = createElement('tbody');
const tbody = createElement("tbody");
for (const kh of this._knownHosts) {
const tr = createElement('tr');
const tr = createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(kh.host)}</td>
<td>${kh.port}</td>
@@ -348,18 +464,20 @@ class Settings {
<td>${formatTimestamp(kh.firstSeen)}</td>
<td></td>
`;
const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' });
deleteBtn.addEventListener('click', async () => {
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
textContent: "Remove",
});
deleteBtn.addEventListener("click", async () => {
try {
await window.go.api.Service.DeleteKnownHost(kh.id);
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
this._renderTabContent(body);
this.app.notifications.success('Known host removed');
this.app.notifications.success("Known host removed");
} catch (err) {
this.app.notifications.error('Failed to remove: ' + err);
this.app.notifications.error("Failed to remove: " + err);
}
});
tr.querySelector('td:last-child').appendChild(deleteBtn);
tr.querySelector("td:last-child").appendChild(deleteBtn);
tbody.appendChild(tr);
}
@@ -374,7 +492,10 @@ class Settings {
*/
_renderHelpTab(body) {
const HELP_SECTIONS = [
{ id: 'getting-started', title: 'Getting Started', content: `
{
id: "getting-started",
title: "Getting Started",
content: `
<p>Welcome to <strong>Nexus Terminal</strong> — a desktop application for connecting to remote servers via SSH, Telnet, and RAW TCP, as well as browsing your GitHub repositories.</p>
<h4>Creating Your First Connection</h4>
<ol>
@@ -388,8 +509,12 @@ class Settings {
<p>Need to connect quickly without saving a profile? Press <kbd>⌘⇧Q</kbd> / <kbd>Ctrl+Shift+Q</kbd> to open the quick connect dialog. Choose SSH, Telnet, or RAW, enter the host and port, and you're connected immediately.</p>
<h4>Managing Connections</h4>
<p>Right-click any connection in the sidebar to <strong>edit</strong>, <strong>duplicate</strong>, or <strong>delete</strong> it. You can drag connections between groups, and use the search box to filter by name, host, or tag.</p>
` },
{ id: 'ssh', title: 'SSH Connections', content: `
`,
},
{
id: "ssh",
title: "SSH Connections",
content: `
<p>SSH (Secure Shell) is the most common way to connect to remote servers. Nexus Terminal supports:</p>
<ul>
<li><strong>Password authentication</strong> — enter your password when prompted, or save it in the encrypted vault.</li>
@@ -420,8 +545,12 @@ class Settings {
<p>Each wait step has a configurable timeout (default 10 seconds). Steps execute in order after the connection is established.</p>
<h4>Auto-Reconnect</h4>
<p>Enable <strong>Auto-Reconnect</strong> in the Advanced section to automatically reconnect when the connection drops unexpectedly. Reconnection uses exponential backoff (1s, 2s, 4s, 8s... up to 30s). You can configure the maximum number of attempts (default 5).</p>
` },
{ id: 'telnet', title: 'Telnet Connections', content: `
`,
},
{
id: "telnet",
title: "Telnet Connections",
content: `
<p>Telnet is an older, unencrypted protocol still used for network equipment, legacy systems, and some fun internet services.</p>
<ul>
<li><strong>Option negotiation</strong> — Nexus Terminal handles standard Telnet options (echo, terminal type, window size) automatically.</li>
@@ -432,8 +561,12 @@ class Settings {
<li><strong>Auto-reconnect</strong> — automatically reconnect with exponential backoff when the connection drops.</li>
</ul>
<p><em>Note:</em> Telnet sends everything in plain text. Avoid using it for anything sensitive over an untrusted network.</p>
` },
{ id: 'raw', title: 'RAW TCP Connections', content: `
`,
},
{
id: "raw",
title: "RAW TCP Connections",
content: `
<p>RAW TCP lets you connect to any TCP port and exchange data directly — useful for debugging network services, testing APIs, or working with custom protocols.</p>
<ul>
<li><strong>Text view</strong> — shows incoming data as readable text.</li>
@@ -443,8 +576,12 @@ class Settings {
<li><strong>Line endings</strong> — choose CR, LF, or CRLF to match the remote service.</li>
<li><strong>Auto-reconnect</strong> — automatically reconnect if the connection drops.</li>
</ul>
` },
{ id: 'github', title: 'GitHub Connections', content: `
`,
},
{
id: "github",
title: "GitHub Connections",
content: `
<p>The GitHub connection type lets you browse your repositories without leaving Nexus Terminal.</p>
<h4>Setting Up</h4>
<ol>
@@ -454,8 +591,12 @@ class Settings {
</ol>
<h4>What You'll See</h4>
<p>Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.</p>
` },
{ id: 'sessions', title: 'Sessions & Tabs', content: `
`,
},
{
id: "sessions",
title: "Sessions & Tabs",
content: `
<h4>Tab Management</h4>
<ul>
<li><strong>Rename</strong> — right-click a tab and choose "Rename".</li>
@@ -484,8 +625,12 @@ class Settings {
<li><strong>Send Snippet</strong> — quickly send a saved command from the snippet library</li>
<li><strong>Clear</strong> / <strong>Reset</strong> — clear the terminal buffer</li>
</ul>
` },
{ id: 'power', title: 'Power Features', content: `
`,
},
{
id: "power",
title: "Power Features",
content: `
<h4>Snippet Library</h4>
<p>Press <kbd>⌘⇧S</kbd> / <kbd>Ctrl+Shift+S</kbd> to open the <strong>snippet library</strong>. Save frequently-used commands with a name, description, and optional folder and tags. Click <strong>Send to Session</strong> to inject a command into the active terminal. You can also access snippets from the right-click context menu.</p>
<h4>Terminal Search</h4>
@@ -502,8 +647,12 @@ class Settings {
<p>For SSH and Telnet connections, you can define expect-style login scripts — a sequence of "wait for text" and "send text" steps that execute automatically after connecting. Configure them in the <strong>Login Script</strong> section of the profile editor.</p>
<h4>Auto-Reconnect</h4>
<p>Enable auto-reconnect in a profile's settings to automatically reconnect when a connection drops. Uses exponential backoff with a configurable maximum number of attempts.</p>
` },
{ id: 'security', title: 'Security & Credentials', content: `
`,
},
{
id: "security",
title: "Security & Credentials",
content: `
<h4>How Your Passwords Are Stored</h4>
<p>Nexus Terminal uses a <strong>two-tier</strong> approach to keep your credentials safe:</p>
<ol>
@@ -519,8 +668,12 @@ class Settings {
<p>You can manage known hosts and change the verification policy in <strong>Settings → Known Hosts</strong>.</p>
<h4>Encrypted Export</h4>
<p>When exporting profiles, you can choose encrypted export (.nexus file) which protects the file with a password. This uses the same strong AES-256-GCM encryption as the vault.</p>
` },
{ id: 'shortcuts', title: 'Keyboard Shortcuts', content: `
`,
},
{
id: "shortcuts",
title: "Keyboard Shortcuts",
content: `
<p>On macOS, use <kbd>⌘</kbd> (Cmd). On Windows and Linux, use <kbd>Ctrl</kbd>.</p>
<table class="help-shortcuts-table">
<tbody>
@@ -541,16 +694,24 @@ class Settings {
<tr><td><kbd>F11</kbd></td><td>Toggle fullscreen (Windows/Linux)</td></tr>
</tbody>
</table>
` },
{ id: 'import-export', title: 'Import & Export', content: `
`,
},
{
id: "import-export",
title: "Import & Export",
content: `
<h4>Exporting Profiles</h4>
<p>Click the <strong>↗</strong> export button in the sidebar footer. You can choose which profiles to include, and set a password to encrypt the file. Exported files use the <code>.nexus</code> extension.</p>
<h4>Importing Profiles</h4>
<p>Click the <strong>↙</strong> import button. Select a <code>.nexus</code> file and enter the password used when exporting. Imported profiles will appear in your sidebar immediately.</p>
<h4>Importing from OpenSSH</h4>
<p>If you have an existing <code>~/.ssh/config</code> file, go to <strong>Settings → About</strong> and click <strong>Import ~/.ssh/config</strong>. Nexus Terminal will create profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.</p>
` },
{ id: 'troubleshooting', title: 'Troubleshooting', content: `
`,
},
{
id: "troubleshooting",
title: "Troubleshooting",
content: `
<h4>I can't connect to my server</h4>
<ul>
<li>Double-check the <strong>hostname</strong> and <strong>port</strong> — a common mistake is using port 22 for Telnet (which is usually 23) or vice versa.</li>
@@ -599,8 +760,12 @@ class Settings {
<p>Make sure your system display scaling is set correctly. On macOS, Nexus Terminal is fully Retina-aware. On Windows, check your DPI settings in Display preferences.</p>
<h4>Export/import isn't working</h4>
<p>Ensure you're using the same password to import that was used to export. The encrypted <code>.nexus</code> files cannot be opened with the wrong password.</p>
` },
{ id: 'fun', title: 'Fun Things to Try', content: `
`,
},
{
id: "fun",
title: "Fun Things to Try",
content: `
<p>Nexus Terminal isn't just for work! Here are some entertaining things you can connect to:</p>
<h4>🌟 Star Wars in ASCII Art</h4>
<p>Create a <strong>Telnet</strong> connection to <code>towel.blinkenlights.nl</code> on port <strong>23</strong>. Sit back and enjoy the original Star Wars film retold entirely in ASCII art.</p>
@@ -616,28 +781,37 @@ class Settings {
<p>Create a <strong>RAW TCP</strong> connection to any HTTP server on port <strong>80</strong>, then type <code>GET / HTTP/1.0</code> followed by two blank lines. You'll see the raw HTTP response — a great way to understand how the web works under the hood.</p>
<h4>📢 Broadcast Mode Fun</h4>
<p>Open two or more sessions (e.g. to different servers) and press <kbd>⌘⇧B</kbd> / <kbd>Ctrl+Shift+B</kbd> to turn on broadcast mode. Everything you type goes to all sessions at once — try running <code>hostname</code> and watch them all respond with different names!</p>
` },
`,
},
];
body.innerHTML = '';
body.innerHTML = "";
// Navigation sidebar
body.classList.add('help-layout');
var nav = createElement('div', 'help-nav');
var content = createElement('div', 'help-content');
body.classList.add("help-layout");
var nav = createElement("div", "help-nav");
var content = createElement("div", "help-content");
for (var i = 0; i < HELP_SECTIONS.length; i++) {
var section = HELP_SECTIONS[i];
var navItem = createElement('button', 'help-nav-item', {
var navItem = createElement("button", "help-nav-item", {
textContent: section.title,
dataset: { section: section.id },
});
navItem.addEventListener('mousedown', function(e) { e.preventDefault(); });
navItem.addEventListener("mousedown", function (e) {
e.preventDefault();
});
(function (sec) {
navItem.addEventListener('click', function() {
nav.querySelectorAll('.help-nav-item').forEach(function(n) { n.classList.remove('active'); });
this.classList.add('active');
content.innerHTML = '<h3 class="help-section-title">' + sec.title + '</h3>' + sec.content;
navItem.addEventListener("click", function () {
nav.querySelectorAll(".help-nav-item").forEach(function (n) {
n.classList.remove("active");
});
this.classList.add("active");
content.innerHTML =
'<h3 class="help-section-title">' +
sec.title +
"</h3>" +
sec.content;
});
})(section);
nav.appendChild(navItem);
@@ -648,8 +822,12 @@ class Settings {
// Show first section by default
if (nav.firstChild) {
nav.firstChild.classList.add('active');
content.innerHTML = '<h3 class="help-section-title">' + HELP_SECTIONS[0].title + '</h3>' + HELP_SECTIONS[0].content;
nav.firstChild.classList.add("active");
content.innerHTML =
'<h3 class="help-section-title">' +
HELP_SECTIONS[0].title +
"</h3>" +
HELP_SECTIONS[0].content;
}
}
@@ -678,44 +856,52 @@ class Settings {
</div>
`;
const updateBtn = body.querySelector('#settings-check-update');
const updateBtn = body.querySelector("#settings-check-update");
if (updateBtn) {
updateBtn.addEventListener('click', async () => {
updateBtn.addEventListener("click", async () => {
updateBtn.disabled = true;
updateBtn.textContent = 'Checking…';
updateBtn.textContent = "Checking…";
try {
const info = await window.go.api.Service.CheckForUpdates();
const resultEl = body.querySelector('#settings-update-result');
const resultEl = body.querySelector("#settings-update-result");
if (info.available) {
resultEl.innerHTML = `<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
(info.releaseURL ? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>` : '');
resultEl.innerHTML =
`<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
(info.releaseURL
? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>`
: "");
} else {
resultEl.textContent = 'You are running the latest version.';
resultEl.textContent = "You are running the latest version.";
}
} catch (err) {
const resultEl = body.querySelector('#settings-update-result');
resultEl.textContent = 'Update check failed: ' + err;
const resultEl = body.querySelector("#settings-update-result");
resultEl.textContent = "Update check failed: " + err;
} finally {
updateBtn.disabled = false;
updateBtn.textContent = 'Check for Updates';
updateBtn.textContent = "Check for Updates";
}
});
}
const importBtn = body.querySelector('#settings-import-sshconfig');
const importBtn = body.querySelector("#settings-import-sshconfig");
if (importBtn) {
importBtn.addEventListener('click', async () => {
importBtn.addEventListener("click", async () => {
importBtn.disabled = true;
importBtn.textContent = 'Importing…';
importBtn.textContent = "Importing…";
try {
const count = await window.go.api.Service.ImportSSHConfig('', 'SSH Config');
this.app.notifications.success(`Imported ${count} host(s) from ~/.ssh/config`);
const count = await window.go.api.Service.ImportSSHConfig(
"",
"SSH Config",
);
this.app.notifications.success(
`Imported ${count} host(s) from ~/.ssh/config`,
);
await this.app.loadProfiles();
} catch (err) {
this.app.notifications.error('SSH config import failed: ' + err);
this.app.notifications.error("SSH config import failed: " + err);
} finally {
importBtn.disabled = false;
importBtn.textContent = 'Import ~/.ssh/config';
importBtn.textContent = "Import ~/.ssh/config";
}
});
}
@@ -734,54 +920,62 @@ class Settings {
* @private
*/
_settingField(type, name, label, value, opts = {}) {
const group = createElement('div', 'form-group settings-field');
const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
const group = createElement("div", "form-group settings-field");
const labelEl = createElement("label", "form-label", {
textContent: label,
for: `setting-${name}`,
});
group.appendChild(labelEl);
let input;
switch (type) {
case 'text':
input = createElement('input', 'form-input', {
type: 'text',
case "text":
input = createElement("input", "form-input", {
type: "text",
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
placeholder: opts.placeholder || '',
value: value || "",
placeholder: opts.placeholder || "",
});
break;
case 'number':
input = createElement('input', 'form-input', {
type: 'number',
case "number":
input = createElement("input", "form-input", {
type: "number",
id: `setting-${name}`,
name: `setting-${name}`,
value: value != null ? String(value) : '',
value: value != null ? String(value) : "",
});
if (opts.min != null) input.min = opts.min;
if (opts.max != null) input.max = opts.max;
break;
case 'select':
input = createElement('select', 'form-select', {
case "select":
input = createElement("select", "form-select", {
id: `setting-${name}`,
name: `setting-${name}`,
});
for (const opt of (opts.options || [])) {
const option = createElement('option', '', { value: opt.value, textContent: opt.label });
for (const opt of opts.options || []) {
const option = createElement("option", "", {
value: opt.value,
textContent: opt.label,
});
if (opt.value === value) option.selected = true;
input.appendChild(option);
}
break;
case 'toggle': {
const wrapper = createElement('div', 'form-toggle-wrapper');
input = createElement('input', 'form-toggle', {
type: 'checkbox',
case "toggle": {
const wrapper = createElement("div", "form-toggle-wrapper");
input = createElement("input", "form-toggle", {
type: "checkbox",
id: `setting-${name}`,
name: `setting-${name}`,
});
input.checked = !!value;
const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` });
const slider = createElement("label", "form-toggle-label", {
for: `setting-${name}`,
});
wrapper.appendChild(input);
wrapper.appendChild(slider);
group.appendChild(wrapper);
@@ -789,11 +983,11 @@ class Settings {
}
default:
input = createElement('input', 'form-input', {
type: 'text',
input = createElement("input", "form-input", {
type: "text",
id: `setting-${name}`,
name: `setting-${name}`,
value: value || '',
value: value || "",
});
}
@@ -811,15 +1005,23 @@ class Settings {
const val = (name) => {
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
if (!el) return undefined;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
if (el.type === "checkbox") return el.checked;
if (el.type === "number") return el.value !== "" ? Number(el.value) : 0;
return el.value;
};
const fields = [
'theme', 'terminalTheme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
'defaultScrollback', 'defaultCursorStyle',
"theme",
"terminalTheme",
"defaultProtocol",
"confirmOnClose",
"confirmOnDisconnect",
"showStatusBar",
"sidebarWidth",
"defaultFontFamily",
"defaultFontSize",
"defaultScrollback",
"defaultCursorStyle",
];
for (const field of fields) {
@@ -833,5 +1035,4 @@ class Settings {
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.Settings = Settings;
})();
+209 -139
View File
@@ -5,7 +5,14 @@
* and context menus.
*/
const { createElement, escapeHtml, fuzzyMatch, protocolColour, dismissContextMenus, truncate } = window.NexusTerminal;
const {
createElement,
escapeHtml,
fuzzyMatch,
protocolColour,
dismissContextMenus,
truncate,
} = window.NexusTerminal;
/** Maximum number of profiles shown in the "Recent" section. */
const RECENT_LIMIT = 5;
@@ -16,8 +23,8 @@ class Sidebar {
*/
constructor(app) {
this.app = app;
this.el = document.getElementById('sidebar-profiles');
this.searchQuery = '';
this.el = document.getElementById("sidebar-profiles");
this.searchQuery = "";
/** @type {Set<string>} */
this.selectedProfiles = new Set();
@@ -26,11 +33,14 @@ class Sidebar {
this.collapsedSections = new Set();
// Dismiss context menus on any outside click
document.addEventListener('click', () => dismissContextMenus());
document.addEventListener("click", () => dismissContextMenus());
if (this.el) {
this.el.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) {
this.el.addEventListener("click", (e) => {
if (
!e.target.closest(".sidebar-item") &&
this.selectedProfiles.size > 0
) {
this._clearSelection();
}
});
@@ -49,7 +59,7 @@ class Sidebar {
*/
render() {
if (!this.el) return;
this.el.innerHTML = '';
this.el.innerHTML = "";
const profiles = this._filteredProfiles();
this._syncSelection(profiles);
@@ -61,7 +71,7 @@ class Sidebar {
// Favourites
const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) {
this._renderSection('★ Favourites', 'favourites', favourites);
this._renderSection("★ Favourites", "favourites", favourites);
}
// Recent
@@ -70,7 +80,7 @@ class Sidebar {
.sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected))
.slice(0, RECENT_LIMIT);
if (recent.length > 0) {
this._renderSection('Recent', 'recent', recent);
this._renderSection("Recent", "recent", recent);
}
// ── Group-based sections ─────────────────────────────────────
@@ -79,7 +89,7 @@ class Sidebar {
const ungrouped = [];
for (const p of profiles) {
const grp = (p.folder || '').trim();
const grp = (p.folder || "").trim();
if (grp) {
if (!groupMap.has(grp)) groupMap.set(grp, []);
groupMap.get(grp).push(p);
@@ -89,20 +99,22 @@ class Sidebar {
}
// Render each group as its own collapsible section
const sortedGroups = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
const sortedGroups = [...groupMap.keys()].sort((a, b) =>
a.localeCompare(b),
);
for (const grp of sortedGroups) {
this._renderSection(grp, `group:${grp}`, groupMap.get(grp));
}
// Ungrouped connections
if (ungrouped.length > 0) {
const label = sortedGroups.length > 0 ? 'Ungrouped' : 'All Connections';
this._renderSection(label, 'ungrouped', ungrouped);
const label = sortedGroups.length > 0 ? "Ungrouped" : "All Connections";
this._renderSection(label, "ungrouped", ungrouped);
}
// Empty state
if (profiles.length === 0) {
const empty = createElement('div', 'sidebar-empty');
const empty = createElement("div", "sidebar-empty");
empty.innerHTML = `
<p class="sidebar-empty-text">No profiles found</p>
`;
@@ -124,8 +136,11 @@ class Sidebar {
* @param {string|null} profileId
*/
highlightActive(profileId) {
this.el.querySelectorAll('.sidebar-item').forEach((item) => {
item.classList.toggle('sidebar-item-active', item.dataset.profileId === profileId);
this.el.querySelectorAll(".sidebar-item").forEach((item) => {
item.classList.toggle(
"sidebar-item-active",
item.dataset.profileId === profileId,
);
});
}
@@ -152,29 +167,29 @@ class Sidebar {
* @private
*/
_renderSection(title, key, profiles) {
const section = createElement('div', 'sidebar-section');
const section = createElement("div", "sidebar-section");
const isCollapsed = this.collapsedSections.has(key);
const isGroupSection = key.startsWith('group:');
const isGroupSection = key.startsWith("group:");
// Section header
const header = createElement(
'div',
`sidebar-section-header ${isGroupSection ? 'sidebar-section-header-group' : ''}`.trim()
"div",
`sidebar-section-header ${isGroupSection ? "sidebar-section-header-group" : ""}`.trim(),
);
header.innerHTML = `
<span class="section-chevron ${isCollapsed ? 'collapsed' : ''}">${isCollapsed ? '▸' : '▾'}</span>
<span class="section-chevron ${isCollapsed ? "collapsed" : ""}">${isCollapsed ? "▸" : "▾"}</span>
<span class="section-title">${escapeHtml(title)}</span>
<span class="section-count">${profiles.length}</span>
`;
// Prevent mousedown from starting a text selection gesture in WKWebView,
// which would swallow the first click and require a double-click.
header.addEventListener('mousedown', (e) => e.preventDefault());
header.addEventListener('click', () => this.toggleSection(key));
header.addEventListener("mousedown", (e) => e.preventDefault());
header.addEventListener("click", () => this.toggleSection(key));
section.appendChild(header);
// Items
if (!isCollapsed) {
const list = createElement('div', 'sidebar-list');
const list = createElement("div", "sidebar-list");
for (const profile of profiles) {
list.appendChild(this.renderProfileItem(profile));
}
@@ -190,12 +205,12 @@ class Sidebar {
* @returns {HTMLElement}
*/
renderProfileItem(profile) {
const item = createElement('div', 'sidebar-item', {
const item = createElement("div", "sidebar-item", {
dataset: { profileId: profile.id },
});
if (this.selectedProfiles.has(profile.id)) {
item.classList.add('sidebar-item-selected');
item.classList.add("sidebar-item-selected");
}
// Highlight if this profile's session is active
@@ -203,39 +218,43 @@ class Sidebar {
? this.app.state.getSession(this.app.state.activeSessionId)
: null;
if (activeSession && activeSession.profileId === profile.id) {
item.classList.add('sidebar-item-active');
item.classList.add("sidebar-item-active");
}
// Protocol icon
const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol);
const indicator = createElement('span', `protocol-indicator ${protocolColour(profile.protocol)}`, {
const indicator = createElement(
"span",
`protocol-indicator ${protocolColour(profile.protocol)}`,
{
title: protocolLabel,
'aria-label': protocolLabel,
});
"aria-label": protocolLabel,
},
);
item.appendChild(indicator);
// Text content
const textWrapper = createElement('div', 'sidebar-item-text');
const displayName = (profile.displayName || '').trim();
const primaryLabel = displayName || profile.host || 'Untitled';
const nameEl = createElement('span', 'sidebar-item-name', {
const textWrapper = createElement("div", "sidebar-item-text");
const displayName = (profile.displayName || "").trim();
const primaryLabel = displayName || profile.host || "Untitled";
const nameEl = createElement("span", "sidebar-item-name", {
textContent: primaryLabel,
});
textWrapper.appendChild(nameEl);
const subtitle = protocolLabel;
const subEl = createElement('span', 'sidebar-item-host', {
const subEl = createElement("span", "sidebar-item-host", {
textContent: truncate(subtitle, 30),
});
textWrapper.appendChild(subEl);
item.appendChild(textWrapper);
// Edit button (visible on hover)
const editBtn = createElement('button', 'sidebar-item-edit', {
textContent: '✎',
title: 'Edit profile',
const editBtn = createElement("button", "sidebar-item-edit", {
textContent: "✎",
title: "Edit profile",
});
editBtn.addEventListener('click', (e) => {
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.app.profileEditor.openEdit(profile.id);
});
@@ -243,15 +262,17 @@ class Sidebar {
// Favourite star
if (profile.favourite) {
const star = createElement('span', 'sidebar-item-star is-favourite', { textContent: '★' });
const star = createElement("span", "sidebar-item-star is-favourite", {
textContent: "★",
});
item.appendChild(star);
}
// Prevent mousedown text selection in WKWebView
item.addEventListener('mousedown', (e) => e.preventDefault());
item.addEventListener("mousedown", (e) => e.preventDefault());
// Click → connect / multi-select
item.addEventListener('click', (e) => {
item.addEventListener("click", (e) => {
const multi = e.metaKey || e.ctrlKey;
if (multi) {
e.preventDefault();
@@ -266,13 +287,13 @@ class Sidebar {
});
// Double-click → edit
item.addEventListener('dblclick', (e) => {
item.addEventListener("dblclick", (e) => {
e.stopPropagation();
this.app.profileEditor.openEdit(profile.id);
});
// Right-click → context menu
item.addEventListener('contextmenu', (e) => {
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
this.renderContextMenu(profile, e.clientX, e.clientY);
@@ -290,36 +311,45 @@ class Sidebar {
renderContextMenu(profile, x, y) {
dismissContextMenus();
const menu = createElement('div', 'context-menu');
const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const items = [
{ label: 'Connect', action: () => this.app.connectProfile(profile.id) },
{ label: 'Edit', action: () => this.app.profileEditor.openEdit(profile.id) },
{ label: 'Duplicate', action: () => this._duplicateProfile(profile) },
{ label: "Connect", action: () => this.app.connectProfile(profile.id) },
{
label: "Edit",
action: () => this.app.profileEditor.openEdit(profile.id),
},
{ label: "Duplicate", action: () => this._duplicateProfile(profile) },
{ divider: true },
{
label: profile.favourite ? 'Remove from Favourites' : 'Add to Favourites',
label: profile.favourite
? "Remove from Favourites"
: "Add to Favourites",
action: () => this._toggleFavourite(profile),
},
{ divider: true },
{
label: 'Delete',
className: 'context-menu-danger',
label: "Delete",
className: "context-menu-danger",
action: () => this._deleteProfile(profile),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement('div', 'context-menu-divider'));
menu.appendChild(createElement("div", "context-menu-divider"));
continue;
}
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
const btn = createElement(
"button",
`context-menu-item ${entry.className || ""}`,
{
textContent: entry.label,
});
btn.addEventListener('click', () => {
},
);
btn.addEventListener("click", () => {
dismissContextMenus();
entry.action();
});
@@ -352,7 +382,7 @@ class Sidebar {
this.app.state.updateProfile(profile);
this.render();
} catch (err) {
this.app.notifications.error('Failed to update favourite: ' + err);
this.app.notifications.error("Failed to update favourite: " + err);
}
}
@@ -368,7 +398,7 @@ class Sidebar {
this.render();
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
} catch (err) {
this.app.notifications.error('Failed to duplicate profile: ' + err);
this.app.notifications.error("Failed to duplicate profile: " + err);
}
}
@@ -387,9 +417,9 @@ class Sidebar {
await window.go.api.Service.DeleteProfile(profile.id);
this.app.state.removeProfile(profile.id);
this.render();
this.app.notifications.success('Profile deleted');
this.app.notifications.success("Profile deleted");
} catch (err) {
this.app.notifications.error('Failed to delete profile: ' + err);
this.app.notifications.error("Failed to delete profile: " + err);
}
}
@@ -401,9 +431,11 @@ class Sidebar {
*/
_confirmDelete(profile) {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
const label = escapeHtml(profile.displayName || profile.host || 'Untitled');
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
const label = escapeHtml(
profile.displayName || profile.host || "Untitled",
);
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Delete Profile</h3>
@@ -422,20 +454,28 @@ class Sidebar {
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove('is-visible');
modal.classList.remove('is-visible');
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); });
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);
});
});
}
@@ -472,31 +512,37 @@ class Sidebar {
/** @private */
_getSelectedProfiles() {
return this.app.state.profiles.filter((p) => this.selectedProfiles.has(p.id));
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', {
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 actions = createElement("div", "sidebar-bulk-buttons");
const makeBtn = (label, handler, className) => {
const btn = createElement('button', `btn btn-xs ${className || ''}`.trim(), {
const btn = createElement(
"button",
`btn btn-xs ${className || ""}`.trim(),
{
textContent: label,
});
btn.addEventListener('click', handler);
},
);
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');
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;
@@ -513,10 +559,11 @@ class Sidebar {
/** @private */
async _bulkDisconnect() {
const sessions = this.app.state.getAllSessions()
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');
this.app.notifications.info("No active sessions to disconnect");
return;
}
@@ -538,7 +585,7 @@ class Sidebar {
for (const p of profiles) {
const updated = JSON.parse(JSON.stringify(p));
const existing = updated.tags || [];
if (result.mode === 'replace') {
if (result.mode === "replace") {
updated.tags = result.tags;
} else {
const merged = new Set([...existing, ...result.tags]);
@@ -549,7 +596,7 @@ class Sidebar {
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.app.notifications.error("Failed to update tags: " + err);
}
}
@@ -570,7 +617,7 @@ class Sidebar {
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.app.notifications.error("Failed to move group: " + err);
}
}
@@ -581,8 +628,8 @@ class Sidebar {
/** @private */
_confirmBulkDisconnect(count) {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
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>
@@ -590,7 +637,7 @@ class Sidebar {
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Disconnect ${count} active ${count === 1 ? 'session' : 'sessions'}?
Disconnect ${count} active ${count === 1 ? "session" : "sessions"}?
</p>
</div>
<div class="modal-footer">
@@ -601,28 +648,36 @@ class Sidebar {
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const close = (val) => {
backdrop.classList.remove('is-visible');
modal.classList.remove('is-visible');
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); });
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');
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>
@@ -643,31 +698,39 @@ class Sidebar {
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector('.tag-input');
const input = modal.querySelector(".tag-input");
const parseTags = () => {
const raw = (input.value || '').split(',');
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');
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(".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() });
modal
.querySelector(".confirm-replace")
.addEventListener("click", () => {
close({ mode: "replace", tags: parseTags() });
});
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null);
});
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
setTimeout(() => input.focus(), 50);
});
}
@@ -675,8 +738,8 @@ class Sidebar {
/** @private */
_promptGroupMove() {
return new Promise((resolve) => {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-sm');
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>
@@ -699,21 +762,29 @@ class Sidebar {
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add('is-visible');
modal.classList.add('is-visible');
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector('.group-input');
const input = modal.querySelector(".group-input");
const close = (val) => {
backdrop.classList.remove('is-visible');
modal.classList.remove('is-visible');
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); });
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);
});
}
@@ -732,11 +803,11 @@ class Sidebar {
.map((p) => {
// Match against display name, host, tags, and protocol
const fields = [
p.displayName || '',
p.host || '',
(p.tags || []).join(' '),
p.protocol || '',
].join(' ');
p.displayName || "",
p.host || "",
(p.tags || []).join(" "),
p.protocol || "",
].join(" ");
const result = fuzzyMatch(fields, this.searchQuery);
return { profile: p, ...result };
})
@@ -753,21 +824,20 @@ class Sidebar {
*/
_protocolLabel(protocol) {
switch (protocol) {
case 'github':
return 'GitHub';
case 'ssh':
return 'SSH';
case 'telnet':
return 'TELNET';
case 'raw':
return 'RAW';
case "github":
return "GitHub";
case "ssh":
return "SSH";
case "telnet":
return "TELNET";
case "raw":
return "RAW";
default:
return (protocol || '').toUpperCase() || 'UNKNOWN';
return (protocol || "").toUpperCase() || "UNKNOWN";
}
}
}
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.Sidebar = Sidebar;
})();
+13
View File
@@ -214,6 +214,10 @@
searchAddon,
webLinksAddon,
observer,
decoder:
typeof TextDecoder !== "undefined"
? new TextDecoder("utf-8")
: null,
});
return term;
@@ -230,7 +234,16 @@
try {
const raw = atob(base64Data);
if (entry.decoder) {
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
const text = entry.decoder.decode(bytes, { stream: true });
entry.term.write(text);
} else {
entry.term.write(raw);
}
} catch (err) {
console.error("Failed to decode session data:", err);
}