UI Updates, and fix UTF8 issues
This commit is contained in:
@@ -215,7 +215,10 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
cursor: pointer;
|
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,
|
.github-repo-download:hover,
|
||||||
|
|||||||
@@ -104,7 +104,9 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color var(--transition-fast), background var(--transition-fast);
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background var(--transition-fast);
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -209,7 +211,7 @@
|
|||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
}
|
}
|
||||||
.sidebar-item.active::before {
|
.sidebar-item.active::before {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -236,13 +238,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item .protocol-indicator::before {
|
.sidebar-item .protocol-indicator::before {
|
||||||
content: '?';
|
content: "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item .protocol-indicator.proto-ssh::before { content: '⌨'; }
|
.sidebar-item .protocol-indicator.proto-ssh::before {
|
||||||
.sidebar-item .protocol-indicator.proto-telnet::before { content: '⇄'; }
|
content: "⌨";
|
||||||
.sidebar-item .protocol-indicator.proto-raw::before { content: '≋'; }
|
}
|
||||||
.sidebar-item .protocol-indicator.proto-github::before { content: 'GH'; }
|
.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 {
|
.sidebar-item .protocol-indicator.proto-ssh {
|
||||||
background: rgba(78, 205, 196, 0.18);
|
background: rgba(78, 205, 196, 0.18);
|
||||||
@@ -303,7 +313,10 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
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 {
|
.sidebar-item:hover .sidebar-item-edit {
|
||||||
@@ -327,7 +340,9 @@
|
|||||||
color: var(--text-disabled);
|
color: var(--text-disabled);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
opacity: 0;
|
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 {
|
.sidebar-item:hover .sidebar-item-star {
|
||||||
@@ -351,9 +366,16 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: var(--space-1);
|
margin-right: var(--space-1);
|
||||||
}
|
}
|
||||||
.sidebar-item-status.is-connected { background: var(--success); }
|
.sidebar-item-status.is-connected {
|
||||||
.sidebar-item-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
background: var(--success);
|
||||||
.sidebar-item-status.is-error { background: var(--error); }
|
}
|
||||||
|
.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
|
Sidebar empty list placeholder
|
||||||
|
|||||||
+20
-10
@@ -918,10 +918,7 @@
|
|||||||
"permission denied",
|
"permission denied",
|
||||||
"no supported methods",
|
"no supported methods",
|
||||||
];
|
];
|
||||||
const hostKeyPatterns = [
|
const hostKeyPatterns = ["host key", "fingerprint"];
|
||||||
"host key",
|
|
||||||
"fingerprint",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (authPatterns.some((p) => msg.includes(p))) {
|
if (authPatterns.some((p) => msg.includes(p))) {
|
||||||
return {
|
return {
|
||||||
@@ -959,7 +956,11 @@
|
|||||||
const session = this.state.getSession(sessionId);
|
const session = this.state.getSession(sessionId);
|
||||||
const label = session?.tabName || session?.host || "Session";
|
const label = session?.tabName || session?.host || "Session";
|
||||||
const body = `${info.message}\n\n${message}`;
|
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) {
|
if (action === "edit" && session?.profileId) {
|
||||||
this.profileEditor.openEdit(session.profileId);
|
this.profileEditor.openEdit(session.profileId);
|
||||||
}
|
}
|
||||||
@@ -969,7 +970,9 @@
|
|||||||
if (action === "knownhosts") {
|
if (action === "knownhosts") {
|
||||||
this.settings.openTab("knownhosts");
|
this.settings.openTab("knownhosts");
|
||||||
}
|
}
|
||||||
}, info.actions);
|
},
|
||||||
|
info.actions,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1005,15 +1008,20 @@
|
|||||||
|
|
||||||
const footer = modal.querySelector(".modal-footer");
|
const footer = modal.querySelector(".modal-footer");
|
||||||
const addActionBtn = (label, action, primary) => {
|
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,
|
textContent: label,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
btn.addEventListener("click", () => close(action));
|
btn.addEventListener("click", () => close(action));
|
||||||
footer.appendChild(btn);
|
footer.appendChild(btn);
|
||||||
};
|
};
|
||||||
|
|
||||||
addActionBtn("Close", null, false);
|
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("edit")) addActionBtn("Edit Profile", "edit", false);
|
||||||
if (actions.includes("retry")) addActionBtn("Retry", "retry", true);
|
if (actions.includes("retry")) addActionBtn("Retry", "retry", true);
|
||||||
|
|
||||||
@@ -1024,7 +1032,9 @@
|
|||||||
if (action) onAction(action);
|
if (action) onAction(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
modal.querySelector(".modal-close").addEventListener("click", () => close(null));
|
modal
|
||||||
|
.querySelector(".modal-close")
|
||||||
|
.addEventListener("click", () => close(null));
|
||||||
backdrop.addEventListener("click", (e) => {
|
backdrop.addEventListener("click", (e) => {
|
||||||
if (e.target === backdrop) close(null);
|
if (e.target === backdrop) close(null);
|
||||||
});
|
});
|
||||||
|
|||||||
+129
-97
@@ -23,7 +23,11 @@ class GitHubViewer {
|
|||||||
createViewer(sessionID, container, profileName) {
|
createViewer(sessionID, container, profileName) {
|
||||||
if (this._viewers.has(sessionID)) return;
|
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);
|
this._viewers.set(sessionID, viewer);
|
||||||
viewer.render();
|
viewer.render();
|
||||||
}
|
}
|
||||||
@@ -90,13 +94,13 @@ class GitHubViewerInstance {
|
|||||||
this.profileName = profileName;
|
this.profileName = profileName;
|
||||||
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this._login = '';
|
this._login = "";
|
||||||
|
|
||||||
/** @type {Array} */
|
/** @type {Array} */
|
||||||
this._repos = [];
|
this._repos = [];
|
||||||
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this._filter = '';
|
this._filter = "";
|
||||||
|
|
||||||
/** @type {HTMLElement|null} */
|
/** @type {HTMLElement|null} */
|
||||||
this._el = null;
|
this._el = null;
|
||||||
@@ -112,30 +116,30 @@ class GitHubViewerInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.container.innerHTML = '';
|
this.container.innerHTML = "";
|
||||||
|
|
||||||
const wrapper = createElement('div', 'github-viewer');
|
const wrapper = createElement("div", "github-viewer");
|
||||||
|
|
||||||
// ── Header bar ──────────────────────────────────────────
|
// ── 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 titleArea = createElement("div", "github-viewer-title-area");
|
||||||
const icon = createElement('span', 'github-viewer-icon');
|
const icon = createElement("span", "github-viewer-icon");
|
||||||
icon.innerHTML = this._githubSVG();
|
icon.innerHTML = this._githubSVG();
|
||||||
titleArea.appendChild(icon);
|
titleArea.appendChild(icon);
|
||||||
|
|
||||||
const titleText = createElement('span', 'github-viewer-title');
|
const titleText = createElement("span", "github-viewer-title");
|
||||||
titleText.textContent = this.profileName || 'GitHub';
|
titleText.textContent = this.profileName || "GitHub";
|
||||||
titleArea.appendChild(titleText);
|
titleArea.appendChild(titleText);
|
||||||
header.appendChild(titleArea);
|
header.appendChild(titleArea);
|
||||||
|
|
||||||
// Search / filter
|
// Search / filter
|
||||||
const searchWrap = createElement('div', 'github-viewer-search');
|
const searchWrap = createElement("div", "github-viewer-search");
|
||||||
const searchInput = createElement('input', 'github-viewer-search-input', {
|
const searchInput = createElement("input", "github-viewer-search-input", {
|
||||||
type: 'text',
|
type: "text",
|
||||||
placeholder: 'Filter repositories…',
|
placeholder: "Filter repositories…",
|
||||||
});
|
});
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener("input", (e) => {
|
||||||
this._filter = e.target.value.toLowerCase();
|
this._filter = e.target.value.toLowerCase();
|
||||||
this._renderList();
|
this._renderList();
|
||||||
});
|
});
|
||||||
@@ -143,22 +147,22 @@ class GitHubViewerInstance {
|
|||||||
header.appendChild(searchWrap);
|
header.appendChild(searchWrap);
|
||||||
|
|
||||||
// Refresh button
|
// Refresh button
|
||||||
const refreshBtn = createElement('button', 'github-viewer-refresh', {
|
const refreshBtn = createElement("button", "github-viewer-refresh", {
|
||||||
title: 'Refresh now',
|
title: "Refresh now",
|
||||||
});
|
});
|
||||||
refreshBtn.innerHTML = '↻';
|
refreshBtn.innerHTML = "↻";
|
||||||
refreshBtn.addEventListener('click', () => this._manualRefresh());
|
refreshBtn.addEventListener("click", () => this._manualRefresh());
|
||||||
header.appendChild(refreshBtn);
|
header.appendChild(refreshBtn);
|
||||||
|
|
||||||
wrapper.appendChild(header);
|
wrapper.appendChild(header);
|
||||||
|
|
||||||
// ── Status bar ──────────────────────────────────────────
|
// ── Status bar ──────────────────────────────────────────
|
||||||
this._statusEl = createElement('div', 'github-viewer-status');
|
this._statusEl = createElement("div", "github-viewer-status");
|
||||||
this._statusEl.textContent = 'Connecting…';
|
this._statusEl.textContent = "Connecting…";
|
||||||
wrapper.appendChild(this._statusEl);
|
wrapper.appendChild(this._statusEl);
|
||||||
|
|
||||||
// ── Repo list ───────────────────────────────────────────
|
// ── Repo list ───────────────────────────────────────────
|
||||||
this._listEl = createElement('div', 'github-viewer-list');
|
this._listEl = createElement("div", "github-viewer-list");
|
||||||
wrapper.appendChild(this._listEl);
|
wrapper.appendChild(this._listEl);
|
||||||
|
|
||||||
this.container.appendChild(wrapper);
|
this.container.appendChild(wrapper);
|
||||||
@@ -183,8 +187,8 @@ class GitHubViewerInstance {
|
|||||||
*/
|
*/
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
if (!this._statusEl) return;
|
if (!this._statusEl) return;
|
||||||
this._statusEl.classList.remove('github-viewer-status-error');
|
this._statusEl.classList.remove("github-viewer-status-error");
|
||||||
this._statusEl.textContent = message || '';
|
this._statusEl.textContent = message || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,7 +198,7 @@ class GitHubViewerInstance {
|
|||||||
showError(message) {
|
showError(message) {
|
||||||
if (this._statusEl) {
|
if (this._statusEl) {
|
||||||
this._statusEl.textContent = `⚠ ${message}`;
|
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() {
|
_updateStatus() {
|
||||||
if (!this._statusEl) return;
|
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 count = this._repos.length;
|
||||||
const now = new Date().toLocaleTimeString();
|
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() {
|
_renderList() {
|
||||||
if (!this._listEl) return;
|
if (!this._listEl) return;
|
||||||
this._listEl.innerHTML = '';
|
this._listEl.innerHTML = "";
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
let filtered = this._repos;
|
let filtered = this._repos;
|
||||||
if (this._filter) {
|
if (this._filter) {
|
||||||
filtered = this._repos.filter((r) =>
|
filtered = this._repos.filter(
|
||||||
|
(r) =>
|
||||||
r.fullName.toLowerCase().includes(this._filter) ||
|
r.fullName.toLowerCase().includes(this._filter) ||
|
||||||
(r.description || '').toLowerCase().includes(this._filter) ||
|
(r.description || "").toLowerCase().includes(this._filter) ||
|
||||||
(r.language || '').toLowerCase().includes(this._filter)
|
(r.language || "").toLowerCase().includes(this._filter),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
const empty = createElement('div', 'github-viewer-empty');
|
const empty = createElement("div", "github-viewer-empty");
|
||||||
empty.textContent = this._filter ? 'No matching repositories.' : 'No repositories found.';
|
empty.textContent = this._filter
|
||||||
|
? "No matching repositories."
|
||||||
|
: "No repositories found.";
|
||||||
this._listEl.appendChild(empty);
|
this._listEl.appendChild(empty);
|
||||||
return;
|
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
|
// Group by owner
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const repo of filtered) {
|
for (const repo of filtered) {
|
||||||
const owner = repo.owner || 'unknown';
|
const owner = repo.owner || "unknown";
|
||||||
if (!groups.has(owner)) groups.set(owner, []);
|
if (!groups.has(owner)) groups.set(owner, []);
|
||||||
groups.get(owner).push(repo);
|
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 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 (a === this._login) return -1;
|
||||||
if (b === this._login) return 1;
|
if (b === this._login) return 1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
@@ -260,8 +283,8 @@ class GitHubViewerInstance {
|
|||||||
const repos = groups.get(owner);
|
const repos = groups.get(owner);
|
||||||
|
|
||||||
// Group header
|
// Group header
|
||||||
const groupEl = createElement('div', 'github-group');
|
const groupEl = createElement("div", "github-group");
|
||||||
const groupHeader = createElement('div', 'github-group-header');
|
const groupHeader = createElement("div", "github-group-header");
|
||||||
groupHeader.innerHTML = `
|
groupHeader.innerHTML = `
|
||||||
<span class="github-group-name">${escapeHtml(owner)}</span>
|
<span class="github-group-name">${escapeHtml(owner)}</span>
|
||||||
<span class="github-group-count">${repos.length}</span>
|
<span class="github-group-count">${repos.length}</span>
|
||||||
@@ -283,57 +306,65 @@ class GitHubViewerInstance {
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
_renderRepoCard(repo) {
|
_renderRepoCard(repo) {
|
||||||
const card = createElement('div', 'github-repo-card');
|
const card = createElement("div", "github-repo-card");
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener("click", () => {
|
||||||
if (repo.htmlURL) window.open(repo.htmlURL, '_blank');
|
if (repo.htmlURL) window.open(repo.htmlURL, "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top row: name + badges
|
// Top row: name + badges
|
||||||
const topRow = createElement('div', 'github-repo-top');
|
const topRow = createElement("div", "github-repo-top");
|
||||||
const nameEl = createElement('span', 'github-repo-name');
|
const nameEl = createElement("span", "github-repo-name");
|
||||||
nameEl.textContent = repo.name;
|
nameEl.textContent = repo.name;
|
||||||
topRow.appendChild(nameEl);
|
topRow.appendChild(nameEl);
|
||||||
|
|
||||||
|
|
||||||
if (repo.private) {
|
if (repo.private) {
|
||||||
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
const badge = createElement(
|
||||||
badge.textContent = 'Private';
|
"span",
|
||||||
|
"github-repo-badge github-repo-private",
|
||||||
|
);
|
||||||
|
badge.textContent = "Private";
|
||||||
topRow.appendChild(badge);
|
topRow.appendChild(badge);
|
||||||
}
|
}
|
||||||
if (repo.fork) {
|
if (repo.fork) {
|
||||||
const badge = createElement('span', 'github-repo-badge github-repo-fork');
|
const badge = createElement(
|
||||||
badge.textContent = 'Fork';
|
"span",
|
||||||
|
"github-repo-badge github-repo-fork",
|
||||||
|
);
|
||||||
|
badge.textContent = "Fork";
|
||||||
topRow.appendChild(badge);
|
topRow.appendChild(badge);
|
||||||
}
|
}
|
||||||
if (repo.archived) {
|
if (repo.archived) {
|
||||||
const badge = createElement('span', 'github-repo-badge github-repo-archived');
|
const badge = createElement(
|
||||||
badge.textContent = 'Archived';
|
"span",
|
||||||
|
"github-repo-badge github-repo-archived",
|
||||||
|
);
|
||||||
|
badge.textContent = "Archived";
|
||||||
topRow.appendChild(badge);
|
topRow.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = createElement('div', 'github-repo-actions');
|
const actions = createElement("div", "github-repo-actions");
|
||||||
|
|
||||||
const downloadBtn = createElement('button', 'github-repo-download', {
|
const downloadBtn = createElement("button", "github-repo-download", {
|
||||||
textContent: 'ZIP',
|
textContent: "ZIP",
|
||||||
title: 'Download repository as zip',
|
title: "Download repository as zip",
|
||||||
});
|
});
|
||||||
downloadBtn.addEventListener('click', (e) => {
|
downloadBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this._downloadRepo(repo);
|
this._downloadRepo(repo);
|
||||||
});
|
});
|
||||||
actions.appendChild(downloadBtn);
|
actions.appendChild(downloadBtn);
|
||||||
|
|
||||||
const visitLink = createElement('button', 'github-repo-visit', {
|
const visitLink = createElement("button", "github-repo-visit", {
|
||||||
textContent: 'Visit',
|
textContent: "Visit",
|
||||||
title: 'Open repository in browser',
|
title: "Open repository in browser",
|
||||||
});
|
});
|
||||||
visitLink.addEventListener('click', (e) => {
|
visitLink.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (repo.htmlURL) {
|
if (repo.htmlURL) {
|
||||||
if (window.runtime && window.runtime.BrowserOpenURL) {
|
if (window.runtime && window.runtime.BrowserOpenURL) {
|
||||||
window.runtime.BrowserOpenURL(repo.htmlURL);
|
window.runtime.BrowserOpenURL(repo.htmlURL);
|
||||||
} else {
|
} else {
|
||||||
window.open(repo.htmlURL, '_blank');
|
window.open(repo.htmlURL, "_blank");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -344,37 +375,39 @@ class GitHubViewerInstance {
|
|||||||
card.appendChild(topRow);
|
card.appendChild(topRow);
|
||||||
|
|
||||||
if (repo.latestCommitter && repo.latestCommitAt) {
|
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 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}`;
|
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
|
||||||
card.appendChild(commitLine);
|
card.appendChild(commitLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if (repo.description) {
|
if (repo.description) {
|
||||||
const desc = createElement('div', 'github-repo-desc');
|
const desc = createElement("div", "github-repo-desc");
|
||||||
desc.textContent = repo.description;
|
desc.textContent = repo.description;
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta row: language, stars, pushed
|
// Meta row: language, stars, pushed
|
||||||
const meta = createElement('div', 'github-repo-meta');
|
const meta = createElement("div", "github-repo-meta");
|
||||||
|
|
||||||
if (repo.language) {
|
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)}`;
|
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
|
||||||
meta.appendChild(lang);
|
meta.appendChild(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repo.stars > 0) {
|
if (repo.stars > 0) {
|
||||||
const stars = createElement('span', 'github-repo-stars');
|
const stars = createElement("span", "github-repo-stars");
|
||||||
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
|
||||||
meta.appendChild(stars);
|
meta.appendChild(stars);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repo.pushedAt) {
|
if (repo.pushedAt) {
|
||||||
const pushed = createElement('span', 'github-repo-pushed');
|
const pushed = createElement("span", "github-repo-pushed");
|
||||||
pushed.textContent = relativeTime(repo.pushedAt);
|
pushed.textContent = relativeTime(repo.pushedAt);
|
||||||
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
pushed.title = new Date(repo.pushedAt).toLocaleString();
|
||||||
meta.appendChild(pushed);
|
meta.appendChild(pushed);
|
||||||
@@ -387,8 +420,8 @@ class GitHubViewerInstance {
|
|||||||
|
|
||||||
async _downloadRepo(repo) {
|
async _downloadRepo(repo) {
|
||||||
if (!this._statusEl) return;
|
if (!this._statusEl) return;
|
||||||
const ref = repo.defaultBranch || '';
|
const ref = repo.defaultBranch || "";
|
||||||
this._statusEl.classList.remove('github-viewer-status-error');
|
this._statusEl.classList.remove("github-viewer-status-error");
|
||||||
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -400,17 +433,17 @@ class GitHubViewerInstance {
|
|||||||
this._updateStatus();
|
this._updateStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._statusEl.textContent = `⚠ Download failed: ${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() {
|
async _manualRefresh() {
|
||||||
if (!this._statusEl) return;
|
if (!this._statusEl) return;
|
||||||
this._statusEl.textContent = 'Refreshing…';
|
this._statusEl.textContent = "Refreshing…";
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
|
await window.go.api.Service.RefreshGitHubRepos(this.sessionID);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showError(err.message || 'Refresh failed');
|
this.showError(err.message || "Refresh failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,32 +455,32 @@ class GitHubViewerInstance {
|
|||||||
*/
|
*/
|
||||||
_langColour(lang) {
|
_langColour(lang) {
|
||||||
const colours = {
|
const colours = {
|
||||||
'JavaScript': '#f1e05a',
|
JavaScript: "#f1e05a",
|
||||||
'TypeScript': '#3178c6',
|
TypeScript: "#3178c6",
|
||||||
'Python': '#3572A5',
|
Python: "#3572A5",
|
||||||
'Go': '#00ADD8',
|
Go: "#00ADD8",
|
||||||
'Rust': '#dea584',
|
Rust: "#dea584",
|
||||||
'Java': '#b07219',
|
Java: "#b07219",
|
||||||
'C': '#555555',
|
C: "#555555",
|
||||||
'C++': '#f34b7d',
|
"C++": "#f34b7d",
|
||||||
'C#': '#178600',
|
"C#": "#178600",
|
||||||
'Ruby': '#701516',
|
Ruby: "#701516",
|
||||||
'PHP': '#4F5D95',
|
PHP: "#4F5D95",
|
||||||
'Swift': '#F05138',
|
Swift: "#F05138",
|
||||||
'Kotlin': '#A97BFF',
|
Kotlin: "#A97BFF",
|
||||||
'Shell': '#89e051',
|
Shell: "#89e051",
|
||||||
'HTML': '#e34c26',
|
HTML: "#e34c26",
|
||||||
'CSS': '#563d7c',
|
CSS: "#563d7c",
|
||||||
'Dart': '#00B4AB',
|
Dart: "#00B4AB",
|
||||||
'Lua': '#000080',
|
Lua: "#000080",
|
||||||
'Scala': '#c22d40',
|
Scala: "#c22d40",
|
||||||
'Elixir': '#6e4a7e',
|
Elixir: "#6e4a7e",
|
||||||
'Haskell': '#5e5086',
|
Haskell: "#5e5086",
|
||||||
'Zig': '#ec915c',
|
Zig: "#ec915c",
|
||||||
'Vue': '#41b883',
|
Vue: "#41b883",
|
||||||
'SCSS': '#c6538c',
|
SCSS: "#c6538c",
|
||||||
};
|
};
|
||||||
return colours[lang] || '#8b949e';
|
return colours[lang] || "#8b949e";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -461,5 +494,4 @@ class GitHubViewerInstance {
|
|||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
window.NexusTerminal.GitHubViewer = new GitHubViewer();
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1041
-558
File diff suppressed because it is too large
Load Diff
+377
-176
@@ -5,16 +5,17 @@
|
|||||||
* terminal defaults, security, known-hosts management, and about info.
|
* terminal defaults, security, known-hosts management, and about info.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createElement, escapeHtml, formatTimestamp, $ } = window.NexusTerminal;
|
const { createElement, escapeHtml, formatTimestamp, $ } =
|
||||||
|
window.NexusTerminal;
|
||||||
|
|
||||||
/** Settings tab definitions. */
|
/** Settings tab definitions. */
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'general', label: 'General' },
|
{ key: "general", label: "General" },
|
||||||
{ key: 'terminal', label: 'Terminal' },
|
{ key: "terminal", label: "Terminal" },
|
||||||
{ key: 'security', label: 'Security' },
|
{ key: "security", label: "Security" },
|
||||||
{ key: 'knownhosts', label: 'Known Hosts' },
|
{ key: "knownhosts", label: "Known Hosts" },
|
||||||
{ key: 'help', label: 'Help' },
|
{ key: "help", label: "Help" },
|
||||||
{ key: 'about', label: 'About' },
|
{ key: "about", label: "About" },
|
||||||
];
|
];
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
@@ -28,7 +29,7 @@ class Settings {
|
|||||||
this._overlay = null;
|
this._overlay = null;
|
||||||
|
|
||||||
/** @type {string} Currently active tab key. */
|
/** @type {string} Currently active tab key. */
|
||||||
this._activeTab = 'general';
|
this._activeTab = "general";
|
||||||
|
|
||||||
/** @type {Object} Working copy of preferences. */
|
/** @type {Object} Working copy of preferences. */
|
||||||
this._prefs = {};
|
this._prefs = {};
|
||||||
@@ -44,10 +45,10 @@ class Settings {
|
|||||||
*/
|
*/
|
||||||
async open(tabKey) {
|
async open(tabKey) {
|
||||||
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
||||||
this._activeTab = tabKey || this._activeTab || 'general';
|
this._activeTab = tabKey || this._activeTab || "general";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
|
this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || [];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this._knownHosts = [];
|
this._knownHosts = [];
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ class Settings {
|
|||||||
* @param {string} tabKey
|
* @param {string} tabKey
|
||||||
*/
|
*/
|
||||||
async openTab(tabKey) {
|
async openTab(tabKey) {
|
||||||
this._activeTab = tabKey || 'general';
|
this._activeTab = tabKey || "general";
|
||||||
return this.open(this._activeTab);
|
return this.open(this._activeTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +89,14 @@ class Settings {
|
|||||||
if (this.app.terminals && this._prefs.terminalTheme) {
|
if (this.app.terminals && this._prefs.terminalTheme) {
|
||||||
this.app.terminals.applyTheme(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._applyPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.app.notifications.success('Settings saved');
|
this.app.notifications.success("Settings saved");
|
||||||
this.close();
|
this.close();
|
||||||
} catch (err) {
|
} 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() {
|
_render() {
|
||||||
this.close();
|
this.close();
|
||||||
|
|
||||||
const overlay = createElement('div', 'modal-backdrop');
|
const overlay = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement('div', 'modal modal-settings');
|
const modal = createElement("div", "modal modal-settings");
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const header = createElement('div', 'modal-header');
|
const header = createElement("div", "modal-header");
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<h2 class="modal-title">Settings</h2>
|
<h2 class="modal-title">Settings</h2>
|
||||||
<button class="modal-close" aria-label="Close">×</button>
|
<button class="modal-close" aria-label="Close">×</button>
|
||||||
`;
|
`;
|
||||||
header.querySelector('.modal-close').addEventListener('click', () => this.close());
|
header
|
||||||
|
.querySelector(".modal-close")
|
||||||
|
.addEventListener("click", () => this.close());
|
||||||
modal.appendChild(header);
|
modal.appendChild(header);
|
||||||
|
|
||||||
// Tab bar
|
// Tab bar
|
||||||
const tabBar = createElement('div', 'settings-tab-bar');
|
const tabBar = createElement("div", "settings-tab-bar");
|
||||||
for (const tab of TABS) {
|
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,
|
textContent: tab.label,
|
||||||
dataset: { tab: tab.key },
|
dataset: { tab: tab.key },
|
||||||
});
|
},
|
||||||
btn.addEventListener('click', () => {
|
);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
this._activeTab = tab.key;
|
this._activeTab = tab.key;
|
||||||
this._renderBody(modal);
|
this._renderBody(modal);
|
||||||
tabBar.querySelectorAll('.settings-tab').forEach((t) => {
|
tabBar.querySelectorAll(".settings-tab").forEach((t) => {
|
||||||
t.classList.toggle('active', t.dataset.tab === tab.key);
|
t.classList.toggle("active", t.dataset.tab === tab.key);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tabBar.appendChild(btn);
|
tabBar.appendChild(btn);
|
||||||
@@ -136,30 +143,36 @@ class Settings {
|
|||||||
modal.appendChild(tabBar);
|
modal.appendChild(tabBar);
|
||||||
|
|
||||||
// Body (tab content)
|
// Body (tab content)
|
||||||
const body = createElement('div', 'modal-body settings-body');
|
const body = createElement("div", "modal-body settings-body");
|
||||||
modal.appendChild(body);
|
modal.appendChild(body);
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
const footer = createElement('div', 'modal-footer');
|
const footer = createElement("div", "modal-footer");
|
||||||
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
const cancelBtn = createElement("button", "btn btn-secondary", {
|
||||||
const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' });
|
textContent: "Cancel",
|
||||||
cancelBtn.addEventListener('click', () => this.close());
|
});
|
||||||
saveBtn.addEventListener('click', () => this.save());
|
const saveBtn = createElement("button", "btn btn-primary", {
|
||||||
|
textContent: "Save",
|
||||||
|
});
|
||||||
|
cancelBtn.addEventListener("click", () => this.close());
|
||||||
|
saveBtn.addEventListener("click", () => this.save());
|
||||||
footer.appendChild(cancelBtn);
|
footer.appendChild(cancelBtn);
|
||||||
footer.appendChild(saveBtn);
|
footer.appendChild(saveBtn);
|
||||||
modal.appendChild(footer);
|
modal.appendChild(footer);
|
||||||
|
|
||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
|
overlay.addEventListener("click", (e) => {
|
||||||
modal.addEventListener('click', (e) => e.stopPropagation());
|
if (e.target === overlay) this.close();
|
||||||
|
});
|
||||||
|
modal.addEventListener("click", (e) => e.stopPropagation());
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
this._overlay = overlay;
|
this._overlay = overlay;
|
||||||
|
|
||||||
// Trigger visibility after append so CSS transitions animate
|
// Trigger visibility after append so CSS transitions animate
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
overlay.classList.add('is-visible');
|
overlay.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +182,7 @@ class Settings {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_renderBody(modal) {
|
_renderBody(modal) {
|
||||||
const body = modal.querySelector('.settings-body');
|
const body = modal.querySelector(".settings-body");
|
||||||
if (body) this._renderTabContent(body);
|
if (body) this._renderTabContent(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,15 +192,27 @@ class Settings {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_renderTabContent(body) {
|
_renderTabContent(body) {
|
||||||
body.innerHTML = '';
|
body.innerHTML = "";
|
||||||
body.classList.remove('help-layout');
|
body.classList.remove("help-layout");
|
||||||
switch (this._activeTab) {
|
switch (this._activeTab) {
|
||||||
case 'general': this._renderGeneralTab(body); break;
|
case "general":
|
||||||
case 'terminal': this._renderTerminalTab(body); break;
|
this._renderGeneralTab(body);
|
||||||
case 'security': this._renderSecurityTab(body); break;
|
break;
|
||||||
case 'knownhosts': this._renderKnownHostsTab(body); break;
|
case "terminal":
|
||||||
case 'help': this._renderHelpTab(body); break;
|
this._renderTerminalTab(body);
|
||||||
case 'about': this._renderAboutTab(body); break;
|
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) {
|
_renderGeneralTab(body) {
|
||||||
const p = this._prefs;
|
const p = this._prefs;
|
||||||
body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, {
|
body.appendChild(
|
||||||
|
this._settingField("select", "theme", "Theme", p.theme, {
|
||||||
options: [
|
options: [
|
||||||
{ value: 'dark', label: 'Dark' },
|
{ value: "dark", label: "Dark" },
|
||||||
{ value: 'light', label: 'Light' },
|
{ 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: [
|
options: [
|
||||||
{ value: 'ssh', label: 'SSH' },
|
{ value: "ssh", label: "SSH" },
|
||||||
{ value: 'telnet', label: 'Telnet' },
|
{ value: "telnet", label: "Telnet" },
|
||||||
{ value: 'raw', label: 'RAW' },
|
{ 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(
|
||||||
body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 }));
|
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
|
// Terminal colour theme selector
|
||||||
const themes = window.NexusTerminal.listTerminalThemes();
|
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,
|
options: themes,
|
||||||
}));
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, {
|
body.appendChild(
|
||||||
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
this._settingField(
|
||||||
}));
|
"text",
|
||||||
body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 }));
|
"defaultFontFamily",
|
||||||
body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 }));
|
"Font family",
|
||||||
body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, {
|
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: [
|
options: [
|
||||||
{ value: 'block', label: 'Block' },
|
{ value: "block", label: "Block" },
|
||||||
{ value: 'underline', label: 'Underline' },
|
{ value: "underline", label: "Underline" },
|
||||||
{ value: 'bar', label: 'Bar' },
|
{ value: "bar", label: "Bar" },
|
||||||
],
|
],
|
||||||
}));
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -253,59 +357,67 @@ class Settings {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_renderSecurityTab(body) {
|
_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 storeInit = this.app.state.secretStoreInitialised;
|
||||||
|
|
||||||
const statusRow = createElement('div', 'settings-row');
|
const statusRow = createElement("div", "settings-row");
|
||||||
statusRow.innerHTML = `
|
statusRow.innerHTML = `
|
||||||
<span class="settings-label">Secret store</span>
|
<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);
|
body.appendChild(statusRow);
|
||||||
|
|
||||||
if (!storeInit) {
|
if (!storeInit) {
|
||||||
const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' });
|
const initBtn = createElement("button", "btn btn-primary btn-sm", {
|
||||||
initBtn.addEventListener('click', async () => {
|
textContent: "Initialise Secret Store",
|
||||||
|
});
|
||||||
|
initBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const pw = await this.app.secretPrompt.promptMasterPassword(true);
|
const pw = await this.app.secretPrompt.promptMasterPassword(true);
|
||||||
await window.go.api.Service.InitialiseSecretStore(pw);
|
await window.go.api.Service.InitialiseSecretStore(pw);
|
||||||
this.app.state.setSecretStoreInitialised(true);
|
this.app.state.setSecretStoreInitialised(true);
|
||||||
this.app.state.setSecretStoreState(true);
|
this.app.state.setSecretStoreState(true);
|
||||||
this.app.notifications.success('Secret store created');
|
this.app.notifications.success("Secret store created");
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message !== 'User cancelled') {
|
if (err.message !== "User cancelled") {
|
||||||
this.app.notifications.error('Failed to initialise: ' + err);
|
this.app.notifications.error("Failed to initialise: " + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.appendChild(initBtn);
|
body.appendChild(initBtn);
|
||||||
} else if (!this.app.state.secretStoreUnlocked) {
|
} else if (!this.app.state.secretStoreUnlocked) {
|
||||||
const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' });
|
const unlockBtn = createElement("button", "btn btn-primary btn-sm", {
|
||||||
unlockBtn.addEventListener('click', async () => {
|
textContent: "Unlock",
|
||||||
|
});
|
||||||
|
unlockBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||||
await window.go.api.Service.UnlockSecretStore(pw);
|
await window.go.api.Service.UnlockSecretStore(pw);
|
||||||
this.app.state.setSecretStoreState(true);
|
this.app.state.setSecretStoreState(true);
|
||||||
this.app.notifications.success('Secret store unlocked');
|
this.app.notifications.success("Secret store unlocked");
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message !== 'User cancelled') {
|
if (err.message !== "User cancelled") {
|
||||||
this.app.notifications.error('Unlock failed: ' + err);
|
this.app.notifications.error("Unlock failed: " + err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.appendChild(unlockBtn);
|
body.appendChild(unlockBtn);
|
||||||
} else {
|
} else {
|
||||||
const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' });
|
const lockBtn = createElement("button", "btn btn-outline btn-sm", {
|
||||||
lockBtn.addEventListener('click', async () => {
|
textContent: "Lock",
|
||||||
|
});
|
||||||
|
lockBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.LockSecretStore();
|
await window.go.api.Service.LockSecretStore();
|
||||||
this.app.state.setSecretStoreState(false);
|
this.app.state.setSecretStoreState(false);
|
||||||
this.app.notifications.info('Secret store locked');
|
this.app.notifications.info("Secret store locked");
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Lock failed: ' + err);
|
this.app.notifications.error("Lock failed: " + err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.appendChild(lockBtn);
|
body.appendChild(lockBtn);
|
||||||
@@ -319,11 +431,15 @@ class Settings {
|
|||||||
*/
|
*/
|
||||||
_renderKnownHostsTab(body) {
|
_renderKnownHostsTab(body) {
|
||||||
if (this._knownHosts.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = createElement('table', 'settings-table');
|
const table = createElement("table", "settings-table");
|
||||||
table.innerHTML = `
|
table.innerHTML = `
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -336,10 +452,10 @@ class Settings {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
`;
|
`;
|
||||||
const tbody = createElement('tbody');
|
const tbody = createElement("tbody");
|
||||||
|
|
||||||
for (const kh of this._knownHosts) {
|
for (const kh of this._knownHosts) {
|
||||||
const tr = createElement('tr');
|
const tr = createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${escapeHtml(kh.host)}</td>
|
<td>${escapeHtml(kh.host)}</td>
|
||||||
<td>${kh.port}</td>
|
<td>${kh.port}</td>
|
||||||
@@ -348,18 +464,20 @@ class Settings {
|
|||||||
<td>${formatTimestamp(kh.firstSeen)}</td>
|
<td>${formatTimestamp(kh.firstSeen)}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
`;
|
`;
|
||||||
const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' });
|
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||||
deleteBtn.addEventListener('click', async () => {
|
textContent: "Remove",
|
||||||
|
});
|
||||||
|
deleteBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.DeleteKnownHost(kh.id);
|
await window.go.api.Service.DeleteKnownHost(kh.id);
|
||||||
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
|
this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id);
|
||||||
this._renderTabContent(body);
|
this._renderTabContent(body);
|
||||||
this.app.notifications.success('Known host removed');
|
this.app.notifications.success("Known host removed");
|
||||||
} catch (err) {
|
} 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);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +492,10 @@ class Settings {
|
|||||||
*/
|
*/
|
||||||
_renderHelpTab(body) {
|
_renderHelpTab(body) {
|
||||||
const HELP_SECTIONS = [
|
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>
|
<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>
|
<h4>Creating Your First Connection</h4>
|
||||||
<ol>
|
<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>
|
<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>
|
<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>
|
<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>
|
<p>SSH (Secure Shell) is the most common way to connect to remote servers. Nexus Terminal supports:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Password authentication</strong> — enter your password when prompted, or save it in the encrypted vault.</li>
|
<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>
|
<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>
|
<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>
|
<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>
|
<p>Telnet is an older, unencrypted protocol still used for network equipment, legacy systems, and some fun internet services.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Option negotiation</strong> — Nexus Terminal handles standard Telnet options (echo, terminal type, window size) automatically.</li>
|
<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>
|
<li><strong>Auto-reconnect</strong> — automatically reconnect with exponential backoff when the connection drops.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><em>Note:</em> Telnet sends everything in plain text. Avoid using it for anything sensitive over an untrusted network.</p>
|
<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>
|
<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>
|
<ul>
|
||||||
<li><strong>Text view</strong> — shows incoming data as readable text.</li>
|
<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>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>
|
<li><strong>Auto-reconnect</strong> — automatically reconnect if the connection drops.</li>
|
||||||
</ul>
|
</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>
|
<p>The GitHub connection type lets you browse your repositories without leaving Nexus Terminal.</p>
|
||||||
<h4>Setting Up</h4>
|
<h4>Setting Up</h4>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -454,8 +591,12 @@ class Settings {
|
|||||||
</ol>
|
</ol>
|
||||||
<h4>What You'll See</h4>
|
<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>
|
<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>
|
<h4>Tab Management</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Rename</strong> — right-click a tab and choose "Rename".</li>
|
<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>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>
|
<li><strong>Clear</strong> / <strong>Reset</strong> — clear the terminal buffer</li>
|
||||||
</ul>
|
</ul>
|
||||||
` },
|
`,
|
||||||
{ id: 'power', title: 'Power Features', content: `
|
},
|
||||||
|
{
|
||||||
|
id: "power",
|
||||||
|
title: "Power Features",
|
||||||
|
content: `
|
||||||
<h4>Snippet Library</h4>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<h4>How Your Passwords Are Stored</h4>
|
||||||
<p>Nexus Terminal uses a <strong>two-tier</strong> approach to keep your credentials safe:</p>
|
<p>Nexus Terminal uses a <strong>two-tier</strong> approach to keep your credentials safe:</p>
|
||||||
<ol>
|
<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>
|
<p>You can manage known hosts and change the verification policy in <strong>Settings → Known Hosts</strong>.</p>
|
||||||
<h4>Encrypted Export</h4>
|
<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>
|
<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>
|
<p>On macOS, use <kbd>⌘</kbd> (Cmd). On Windows and Linux, use <kbd>Ctrl</kbd>.</p>
|
||||||
<table class="help-shortcuts-table">
|
<table class="help-shortcuts-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -541,16 +694,24 @@ class Settings {
|
|||||||
<tr><td><kbd>F11</kbd></td><td>Toggle fullscreen (Windows/Linux)</td></tr>
|
<tr><td><kbd>F11</kbd></td><td>Toggle fullscreen (Windows/Linux)</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
` },
|
`,
|
||||||
{ id: 'import-export', title: 'Import & Export', content: `
|
},
|
||||||
|
{
|
||||||
|
id: "import-export",
|
||||||
|
title: "Import & Export",
|
||||||
|
content: `
|
||||||
<h4>Exporting Profiles</h4>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<h4>I can't connect to my server</h4>
|
||||||
<ul>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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
|
// Navigation sidebar
|
||||||
body.classList.add('help-layout');
|
body.classList.add("help-layout");
|
||||||
var nav = createElement('div', 'help-nav');
|
var nav = createElement("div", "help-nav");
|
||||||
var content = createElement('div', 'help-content');
|
var content = createElement("div", "help-content");
|
||||||
|
|
||||||
for (var i = 0; i < HELP_SECTIONS.length; i++) {
|
for (var i = 0; i < HELP_SECTIONS.length; i++) {
|
||||||
var section = HELP_SECTIONS[i];
|
var section = HELP_SECTIONS[i];
|
||||||
var navItem = createElement('button', 'help-nav-item', {
|
var navItem = createElement("button", "help-nav-item", {
|
||||||
textContent: section.title,
|
textContent: section.title,
|
||||||
dataset: { section: section.id },
|
dataset: { section: section.id },
|
||||||
});
|
});
|
||||||
navItem.addEventListener('mousedown', function(e) { e.preventDefault(); });
|
navItem.addEventListener("mousedown", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
(function (sec) {
|
(function (sec) {
|
||||||
navItem.addEventListener('click', function() {
|
navItem.addEventListener("click", function () {
|
||||||
nav.querySelectorAll('.help-nav-item').forEach(function(n) { n.classList.remove('active'); });
|
nav.querySelectorAll(".help-nav-item").forEach(function (n) {
|
||||||
this.classList.add('active');
|
n.classList.remove("active");
|
||||||
content.innerHTML = '<h3 class="help-section-title">' + sec.title + '</h3>' + sec.content;
|
});
|
||||||
|
this.classList.add("active");
|
||||||
|
content.innerHTML =
|
||||||
|
'<h3 class="help-section-title">' +
|
||||||
|
sec.title +
|
||||||
|
"</h3>" +
|
||||||
|
sec.content;
|
||||||
});
|
});
|
||||||
})(section);
|
})(section);
|
||||||
nav.appendChild(navItem);
|
nav.appendChild(navItem);
|
||||||
@@ -648,8 +822,12 @@ class Settings {
|
|||||||
|
|
||||||
// Show first section by default
|
// Show first section by default
|
||||||
if (nav.firstChild) {
|
if (nav.firstChild) {
|
||||||
nav.firstChild.classList.add('active');
|
nav.firstChild.classList.add("active");
|
||||||
content.innerHTML = '<h3 class="help-section-title">' + HELP_SECTIONS[0].title + '</h3>' + HELP_SECTIONS[0].content;
|
content.innerHTML =
|
||||||
|
'<h3 class="help-section-title">' +
|
||||||
|
HELP_SECTIONS[0].title +
|
||||||
|
"</h3>" +
|
||||||
|
HELP_SECTIONS[0].content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,44 +856,52 @@ class Settings {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const updateBtn = body.querySelector('#settings-check-update');
|
const updateBtn = body.querySelector("#settings-check-update");
|
||||||
if (updateBtn) {
|
if (updateBtn) {
|
||||||
updateBtn.addEventListener('click', async () => {
|
updateBtn.addEventListener("click", async () => {
|
||||||
updateBtn.disabled = true;
|
updateBtn.disabled = true;
|
||||||
updateBtn.textContent = 'Checking…';
|
updateBtn.textContent = "Checking…";
|
||||||
try {
|
try {
|
||||||
const info = await window.go.api.Service.CheckForUpdates();
|
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) {
|
if (info.available) {
|
||||||
resultEl.innerHTML = `<span style="color:var(--accent)">Update available: <strong>${info.latestVersion}</strong></span>` +
|
resultEl.innerHTML =
|
||||||
(info.releaseURL ? ` — <a href="#" onclick="window.runtime.BrowserOpenURL('${info.releaseURL}');return false;" style="color:var(--accent)">View release</a>` : '');
|
`<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 {
|
} else {
|
||||||
resultEl.textContent = 'You are running the latest version.';
|
resultEl.textContent = "You are running the latest version.";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const resultEl = body.querySelector('#settings-update-result');
|
const resultEl = body.querySelector("#settings-update-result");
|
||||||
resultEl.textContent = 'Update check failed: ' + err;
|
resultEl.textContent = "Update check failed: " + err;
|
||||||
} finally {
|
} finally {
|
||||||
updateBtn.disabled = false;
|
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) {
|
if (importBtn) {
|
||||||
importBtn.addEventListener('click', async () => {
|
importBtn.addEventListener("click", async () => {
|
||||||
importBtn.disabled = true;
|
importBtn.disabled = true;
|
||||||
importBtn.textContent = 'Importing…';
|
importBtn.textContent = "Importing…";
|
||||||
try {
|
try {
|
||||||
const count = await window.go.api.Service.ImportSSHConfig('', 'SSH Config');
|
const count = await window.go.api.Service.ImportSSHConfig(
|
||||||
this.app.notifications.success(`Imported ${count} host(s) from ~/.ssh/config`);
|
"",
|
||||||
|
"SSH Config",
|
||||||
|
);
|
||||||
|
this.app.notifications.success(
|
||||||
|
`Imported ${count} host(s) from ~/.ssh/config`,
|
||||||
|
);
|
||||||
await this.app.loadProfiles();
|
await this.app.loadProfiles();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('SSH config import failed: ' + err);
|
this.app.notifications.error("SSH config import failed: " + err);
|
||||||
} finally {
|
} finally {
|
||||||
importBtn.disabled = false;
|
importBtn.disabled = false;
|
||||||
importBtn.textContent = 'Import ~/.ssh/config';
|
importBtn.textContent = "Import ~/.ssh/config";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -734,54 +920,62 @@ class Settings {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_settingField(type, name, label, value, opts = {}) {
|
_settingField(type, name, label, value, opts = {}) {
|
||||||
const group = createElement('div', 'form-group settings-field');
|
const group = createElement("div", "form-group settings-field");
|
||||||
const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
|
const labelEl = createElement("label", "form-label", {
|
||||||
|
textContent: label,
|
||||||
|
for: `setting-${name}`,
|
||||||
|
});
|
||||||
group.appendChild(labelEl);
|
group.appendChild(labelEl);
|
||||||
|
|
||||||
let input;
|
let input;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'text':
|
case "text":
|
||||||
input = createElement('input', 'form-input', {
|
input = createElement("input", "form-input", {
|
||||||
type: 'text',
|
type: "text",
|
||||||
id: `setting-${name}`,
|
id: `setting-${name}`,
|
||||||
name: `setting-${name}`,
|
name: `setting-${name}`,
|
||||||
value: value || '',
|
value: value || "",
|
||||||
placeholder: opts.placeholder || '',
|
placeholder: opts.placeholder || "",
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'number':
|
case "number":
|
||||||
input = createElement('input', 'form-input', {
|
input = createElement("input", "form-input", {
|
||||||
type: 'number',
|
type: "number",
|
||||||
id: `setting-${name}`,
|
id: `setting-${name}`,
|
||||||
name: `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.min != null) input.min = opts.min;
|
||||||
if (opts.max != null) input.max = opts.max;
|
if (opts.max != null) input.max = opts.max;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'select':
|
case "select":
|
||||||
input = createElement('select', 'form-select', {
|
input = createElement("select", "form-select", {
|
||||||
id: `setting-${name}`,
|
id: `setting-${name}`,
|
||||||
name: `setting-${name}`,
|
name: `setting-${name}`,
|
||||||
});
|
});
|
||||||
for (const opt of (opts.options || [])) {
|
for (const opt of opts.options || []) {
|
||||||
const option = createElement('option', '', { value: opt.value, textContent: opt.label });
|
const option = createElement("option", "", {
|
||||||
|
value: opt.value,
|
||||||
|
textContent: opt.label,
|
||||||
|
});
|
||||||
if (opt.value === value) option.selected = true;
|
if (opt.value === value) option.selected = true;
|
||||||
input.appendChild(option);
|
input.appendChild(option);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle': {
|
case "toggle": {
|
||||||
const wrapper = createElement('div', 'form-toggle-wrapper');
|
const wrapper = createElement("div", "form-toggle-wrapper");
|
||||||
input = createElement('input', 'form-toggle', {
|
input = createElement("input", "form-toggle", {
|
||||||
type: 'checkbox',
|
type: "checkbox",
|
||||||
id: `setting-${name}`,
|
id: `setting-${name}`,
|
||||||
name: `setting-${name}`,
|
name: `setting-${name}`,
|
||||||
});
|
});
|
||||||
input.checked = !!value;
|
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(input);
|
||||||
wrapper.appendChild(slider);
|
wrapper.appendChild(slider);
|
||||||
group.appendChild(wrapper);
|
group.appendChild(wrapper);
|
||||||
@@ -789,11 +983,11 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
input = createElement('input', 'form-input', {
|
input = createElement("input", "form-input", {
|
||||||
type: 'text',
|
type: "text",
|
||||||
id: `setting-${name}`,
|
id: `setting-${name}`,
|
||||||
name: `setting-${name}`,
|
name: `setting-${name}`,
|
||||||
value: value || '',
|
value: value || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,15 +1005,23 @@ class Settings {
|
|||||||
const val = (name) => {
|
const val = (name) => {
|
||||||
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
|
const el = this._overlay.querySelector(`[name="setting-${name}"]`);
|
||||||
if (!el) return undefined;
|
if (!el) return undefined;
|
||||||
if (el.type === 'checkbox') return el.checked;
|
if (el.type === "checkbox") return el.checked;
|
||||||
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
|
if (el.type === "number") return el.value !== "" ? Number(el.value) : 0;
|
||||||
return el.value;
|
return el.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
'theme', 'terminalTheme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
|
"theme",
|
||||||
'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
|
"terminalTheme",
|
||||||
'defaultScrollback', 'defaultCursorStyle',
|
"defaultProtocol",
|
||||||
|
"confirmOnClose",
|
||||||
|
"confirmOnDisconnect",
|
||||||
|
"showStatusBar",
|
||||||
|
"sidebarWidth",
|
||||||
|
"defaultFontFamily",
|
||||||
|
"defaultFontSize",
|
||||||
|
"defaultScrollback",
|
||||||
|
"defaultCursorStyle",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
@@ -833,5 +1035,4 @@ class Settings {
|
|||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
window.NexusTerminal.Settings = Settings;
|
window.NexusTerminal.Settings = Settings;
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+209
-139
@@ -5,7 +5,14 @@
|
|||||||
* and context menus.
|
* 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. */
|
/** Maximum number of profiles shown in the "Recent" section. */
|
||||||
const RECENT_LIMIT = 5;
|
const RECENT_LIMIT = 5;
|
||||||
@@ -16,8 +23,8 @@ class Sidebar {
|
|||||||
*/
|
*/
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.el = document.getElementById('sidebar-profiles');
|
this.el = document.getElementById("sidebar-profiles");
|
||||||
this.searchQuery = '';
|
this.searchQuery = "";
|
||||||
|
|
||||||
/** @type {Set<string>} */
|
/** @type {Set<string>} */
|
||||||
this.selectedProfiles = new Set();
|
this.selectedProfiles = new Set();
|
||||||
@@ -26,11 +33,14 @@ class Sidebar {
|
|||||||
this.collapsedSections = new Set();
|
this.collapsedSections = new Set();
|
||||||
|
|
||||||
// Dismiss context menus on any outside click
|
// Dismiss context menus on any outside click
|
||||||
document.addEventListener('click', () => dismissContextMenus());
|
document.addEventListener("click", () => dismissContextMenus());
|
||||||
|
|
||||||
if (this.el) {
|
if (this.el) {
|
||||||
this.el.addEventListener('click', (e) => {
|
this.el.addEventListener("click", (e) => {
|
||||||
if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) {
|
if (
|
||||||
|
!e.target.closest(".sidebar-item") &&
|
||||||
|
this.selectedProfiles.size > 0
|
||||||
|
) {
|
||||||
this._clearSelection();
|
this._clearSelection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,7 +59,7 @@ class Sidebar {
|
|||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
if (!this.el) return;
|
if (!this.el) return;
|
||||||
this.el.innerHTML = '';
|
this.el.innerHTML = "";
|
||||||
|
|
||||||
const profiles = this._filteredProfiles();
|
const profiles = this._filteredProfiles();
|
||||||
this._syncSelection(profiles);
|
this._syncSelection(profiles);
|
||||||
@@ -61,7 +71,7 @@ class Sidebar {
|
|||||||
// Favourites
|
// Favourites
|
||||||
const favourites = profiles.filter((p) => p.favourite);
|
const favourites = profiles.filter((p) => p.favourite);
|
||||||
if (favourites.length > 0) {
|
if (favourites.length > 0) {
|
||||||
this._renderSection('★ Favourites', 'favourites', favourites);
|
this._renderSection("★ Favourites", "favourites", favourites);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent
|
// Recent
|
||||||
@@ -70,7 +80,7 @@ class Sidebar {
|
|||||||
.sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected))
|
.sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected))
|
||||||
.slice(0, RECENT_LIMIT);
|
.slice(0, RECENT_LIMIT);
|
||||||
if (recent.length > 0) {
|
if (recent.length > 0) {
|
||||||
this._renderSection('Recent', 'recent', recent);
|
this._renderSection("Recent", "recent", recent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group-based sections ─────────────────────────────────────
|
// ── Group-based sections ─────────────────────────────────────
|
||||||
@@ -79,7 +89,7 @@ class Sidebar {
|
|||||||
const ungrouped = [];
|
const ungrouped = [];
|
||||||
|
|
||||||
for (const p of profiles) {
|
for (const p of profiles) {
|
||||||
const grp = (p.folder || '').trim();
|
const grp = (p.folder || "").trim();
|
||||||
if (grp) {
|
if (grp) {
|
||||||
if (!groupMap.has(grp)) groupMap.set(grp, []);
|
if (!groupMap.has(grp)) groupMap.set(grp, []);
|
||||||
groupMap.get(grp).push(p);
|
groupMap.get(grp).push(p);
|
||||||
@@ -89,20 +99,22 @@ class Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render each group as its own collapsible section
|
// 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) {
|
for (const grp of sortedGroups) {
|
||||||
this._renderSection(grp, `group:${grp}`, groupMap.get(grp));
|
this._renderSection(grp, `group:${grp}`, groupMap.get(grp));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ungrouped connections
|
// Ungrouped connections
|
||||||
if (ungrouped.length > 0) {
|
if (ungrouped.length > 0) {
|
||||||
const label = sortedGroups.length > 0 ? 'Ungrouped' : 'All Connections';
|
const label = sortedGroups.length > 0 ? "Ungrouped" : "All Connections";
|
||||||
this._renderSection(label, 'ungrouped', ungrouped);
|
this._renderSection(label, "ungrouped", ungrouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (profiles.length === 0) {
|
if (profiles.length === 0) {
|
||||||
const empty = createElement('div', 'sidebar-empty');
|
const empty = createElement("div", "sidebar-empty");
|
||||||
empty.innerHTML = `
|
empty.innerHTML = `
|
||||||
<p class="sidebar-empty-text">No profiles found</p>
|
<p class="sidebar-empty-text">No profiles found</p>
|
||||||
`;
|
`;
|
||||||
@@ -124,8 +136,11 @@ class Sidebar {
|
|||||||
* @param {string|null} profileId
|
* @param {string|null} profileId
|
||||||
*/
|
*/
|
||||||
highlightActive(profileId) {
|
highlightActive(profileId) {
|
||||||
this.el.querySelectorAll('.sidebar-item').forEach((item) => {
|
this.el.querySelectorAll(".sidebar-item").forEach((item) => {
|
||||||
item.classList.toggle('sidebar-item-active', item.dataset.profileId === profileId);
|
item.classList.toggle(
|
||||||
|
"sidebar-item-active",
|
||||||
|
item.dataset.profileId === profileId,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,29 +167,29 @@ class Sidebar {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_renderSection(title, key, profiles) {
|
_renderSection(title, key, profiles) {
|
||||||
const section = createElement('div', 'sidebar-section');
|
const section = createElement("div", "sidebar-section");
|
||||||
const isCollapsed = this.collapsedSections.has(key);
|
const isCollapsed = this.collapsedSections.has(key);
|
||||||
const isGroupSection = key.startsWith('group:');
|
const isGroupSection = key.startsWith("group:");
|
||||||
|
|
||||||
// Section header
|
// Section header
|
||||||
const header = createElement(
|
const header = createElement(
|
||||||
'div',
|
"div",
|
||||||
`sidebar-section-header ${isGroupSection ? 'sidebar-section-header-group' : ''}`.trim()
|
`sidebar-section-header ${isGroupSection ? "sidebar-section-header-group" : ""}`.trim(),
|
||||||
);
|
);
|
||||||
header.innerHTML = `
|
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-title">${escapeHtml(title)}</span>
|
||||||
<span class="section-count">${profiles.length}</span>
|
<span class="section-count">${profiles.length}</span>
|
||||||
`;
|
`;
|
||||||
// Prevent mousedown from starting a text selection gesture in WKWebView,
|
// Prevent mousedown from starting a text selection gesture in WKWebView,
|
||||||
// which would swallow the first click and require a double-click.
|
// which would swallow the first click and require a double-click.
|
||||||
header.addEventListener('mousedown', (e) => e.preventDefault());
|
header.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
header.addEventListener('click', () => this.toggleSection(key));
|
header.addEventListener("click", () => this.toggleSection(key));
|
||||||
section.appendChild(header);
|
section.appendChild(header);
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
if (!isCollapsed) {
|
if (!isCollapsed) {
|
||||||
const list = createElement('div', 'sidebar-list');
|
const list = createElement("div", "sidebar-list");
|
||||||
for (const profile of profiles) {
|
for (const profile of profiles) {
|
||||||
list.appendChild(this.renderProfileItem(profile));
|
list.appendChild(this.renderProfileItem(profile));
|
||||||
}
|
}
|
||||||
@@ -190,12 +205,12 @@ class Sidebar {
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
renderProfileItem(profile) {
|
renderProfileItem(profile) {
|
||||||
const item = createElement('div', 'sidebar-item', {
|
const item = createElement("div", "sidebar-item", {
|
||||||
dataset: { profileId: profile.id },
|
dataset: { profileId: profile.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.selectedProfiles.has(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
|
// Highlight if this profile's session is active
|
||||||
@@ -203,39 +218,43 @@ class Sidebar {
|
|||||||
? this.app.state.getSession(this.app.state.activeSessionId)
|
? this.app.state.getSession(this.app.state.activeSessionId)
|
||||||
: null;
|
: null;
|
||||||
if (activeSession && activeSession.profileId === profile.id) {
|
if (activeSession && activeSession.profileId === profile.id) {
|
||||||
item.classList.add('sidebar-item-active');
|
item.classList.add("sidebar-item-active");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protocol icon
|
// Protocol icon
|
||||||
const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol);
|
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,
|
title: protocolLabel,
|
||||||
'aria-label': protocolLabel,
|
"aria-label": protocolLabel,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
item.appendChild(indicator);
|
item.appendChild(indicator);
|
||||||
|
|
||||||
// Text content
|
// Text content
|
||||||
const textWrapper = createElement('div', 'sidebar-item-text');
|
const textWrapper = createElement("div", "sidebar-item-text");
|
||||||
const displayName = (profile.displayName || '').trim();
|
const displayName = (profile.displayName || "").trim();
|
||||||
const primaryLabel = displayName || profile.host || 'Untitled';
|
const primaryLabel = displayName || profile.host || "Untitled";
|
||||||
const nameEl = createElement('span', 'sidebar-item-name', {
|
const nameEl = createElement("span", "sidebar-item-name", {
|
||||||
textContent: primaryLabel,
|
textContent: primaryLabel,
|
||||||
});
|
});
|
||||||
textWrapper.appendChild(nameEl);
|
textWrapper.appendChild(nameEl);
|
||||||
|
|
||||||
const subtitle = protocolLabel;
|
const subtitle = protocolLabel;
|
||||||
const subEl = createElement('span', 'sidebar-item-host', {
|
const subEl = createElement("span", "sidebar-item-host", {
|
||||||
textContent: truncate(subtitle, 30),
|
textContent: truncate(subtitle, 30),
|
||||||
});
|
});
|
||||||
textWrapper.appendChild(subEl);
|
textWrapper.appendChild(subEl);
|
||||||
item.appendChild(textWrapper);
|
item.appendChild(textWrapper);
|
||||||
|
|
||||||
// Edit button (visible on hover)
|
// Edit button (visible on hover)
|
||||||
const editBtn = createElement('button', 'sidebar-item-edit', {
|
const editBtn = createElement("button", "sidebar-item-edit", {
|
||||||
textContent: '✎',
|
textContent: "✎",
|
||||||
title: 'Edit profile',
|
title: "Edit profile",
|
||||||
});
|
});
|
||||||
editBtn.addEventListener('click', (e) => {
|
editBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.app.profileEditor.openEdit(profile.id);
|
this.app.profileEditor.openEdit(profile.id);
|
||||||
});
|
});
|
||||||
@@ -243,15 +262,17 @@ class Sidebar {
|
|||||||
|
|
||||||
// Favourite star
|
// Favourite star
|
||||||
if (profile.favourite) {
|
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);
|
item.appendChild(star);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent mousedown text selection in WKWebView
|
// Prevent mousedown text selection in WKWebView
|
||||||
item.addEventListener('mousedown', (e) => e.preventDefault());
|
item.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
|
|
||||||
// Click → connect / multi-select
|
// Click → connect / multi-select
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener("click", (e) => {
|
||||||
const multi = e.metaKey || e.ctrlKey;
|
const multi = e.metaKey || e.ctrlKey;
|
||||||
if (multi) {
|
if (multi) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -266,13 +287,13 @@ class Sidebar {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Double-click → edit
|
// Double-click → edit
|
||||||
item.addEventListener('dblclick', (e) => {
|
item.addEventListener("dblclick", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.app.profileEditor.openEdit(profile.id);
|
this.app.profileEditor.openEdit(profile.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Right-click → context menu
|
// Right-click → context menu
|
||||||
item.addEventListener('contextmenu', (e) => {
|
item.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.renderContextMenu(profile, e.clientX, e.clientY);
|
this.renderContextMenu(profile, e.clientX, e.clientY);
|
||||||
@@ -290,36 +311,45 @@ class Sidebar {
|
|||||||
renderContextMenu(profile, x, y) {
|
renderContextMenu(profile, x, y) {
|
||||||
dismissContextMenus();
|
dismissContextMenus();
|
||||||
|
|
||||||
const menu = createElement('div', 'context-menu');
|
const menu = createElement("div", "context-menu");
|
||||||
menu.style.left = `${x}px`;
|
menu.style.left = `${x}px`;
|
||||||
menu.style.top = `${y}px`;
|
menu.style.top = `${y}px`;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: 'Connect', action: () => this.app.connectProfile(profile.id) },
|
{ label: "Connect", action: () => this.app.connectProfile(profile.id) },
|
||||||
{ label: 'Edit', action: () => this.app.profileEditor.openEdit(profile.id) },
|
{
|
||||||
{ label: 'Duplicate', action: () => this._duplicateProfile(profile) },
|
label: "Edit",
|
||||||
|
action: () => this.app.profileEditor.openEdit(profile.id),
|
||||||
|
},
|
||||||
|
{ label: "Duplicate", action: () => this._duplicateProfile(profile) },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: profile.favourite ? 'Remove from Favourites' : 'Add to Favourites',
|
label: profile.favourite
|
||||||
|
? "Remove from Favourites"
|
||||||
|
: "Add to Favourites",
|
||||||
action: () => this._toggleFavourite(profile),
|
action: () => this._toggleFavourite(profile),
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: "Delete",
|
||||||
className: 'context-menu-danger',
|
className: "context-menu-danger",
|
||||||
action: () => this._deleteProfile(profile),
|
action: () => this._deleteProfile(profile),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const entry of items) {
|
for (const entry of items) {
|
||||||
if (entry.divider) {
|
if (entry.divider) {
|
||||||
menu.appendChild(createElement('div', 'context-menu-divider'));
|
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const btn = createElement('button', `context-menu-item ${entry.className || ''}`, {
|
const btn = createElement(
|
||||||
|
"button",
|
||||||
|
`context-menu-item ${entry.className || ""}`,
|
||||||
|
{
|
||||||
textContent: entry.label,
|
textContent: entry.label,
|
||||||
});
|
},
|
||||||
btn.addEventListener('click', () => {
|
);
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
dismissContextMenus();
|
dismissContextMenus();
|
||||||
entry.action();
|
entry.action();
|
||||||
});
|
});
|
||||||
@@ -352,7 +382,7 @@ class Sidebar {
|
|||||||
this.app.state.updateProfile(profile);
|
this.app.state.updateProfile(profile);
|
||||||
this.render();
|
this.render();
|
||||||
} catch (err) {
|
} 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.render();
|
||||||
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
|
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
|
||||||
} catch (err) {
|
} 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);
|
await window.go.api.Service.DeleteProfile(profile.id);
|
||||||
this.app.state.removeProfile(profile.id);
|
this.app.state.removeProfile(profile.id);
|
||||||
this.render();
|
this.render();
|
||||||
this.app.notifications.success('Profile deleted');
|
this.app.notifications.success("Profile deleted");
|
||||||
} catch (err) {
|
} 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) {
|
_confirmDelete(profile) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = createElement('div', 'modal-backdrop');
|
const backdrop = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement('div', 'modal modal-sm');
|
const modal = createElement("div", "modal modal-sm");
|
||||||
const label = escapeHtml(profile.displayName || profile.host || 'Untitled');
|
const label = escapeHtml(
|
||||||
|
profile.displayName || profile.host || "Untitled",
|
||||||
|
);
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">Delete Profile</h3>
|
<h3 class="modal-title">Delete Profile</h3>
|
||||||
@@ -422,20 +454,28 @@ class Sidebar {
|
|||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = (val) => {
|
const close = (val) => {
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(() => backdrop.remove(), 200);
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
resolve(val);
|
resolve(val);
|
||||||
};
|
};
|
||||||
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
modal
|
||||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
.addEventListener("click", () => close(false));
|
||||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) 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 */
|
/** @private */
|
||||||
_getSelectedProfiles() {
|
_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 */
|
/** @private */
|
||||||
_renderBulkActions() {
|
_renderBulkActions() {
|
||||||
const bar = createElement('div', 'sidebar-bulk-actions');
|
const bar = createElement("div", "sidebar-bulk-actions");
|
||||||
const count = createElement('span', 'sidebar-bulk-count', {
|
const count = createElement("span", "sidebar-bulk-count", {
|
||||||
textContent: `${this.selectedProfiles.size} selected`,
|
textContent: `${this.selectedProfiles.size} selected`,
|
||||||
});
|
});
|
||||||
bar.appendChild(count);
|
bar.appendChild(count);
|
||||||
|
|
||||||
const actions = createElement('div', 'sidebar-bulk-buttons');
|
const actions = createElement("div", "sidebar-bulk-buttons");
|
||||||
const makeBtn = (label, handler, className) => {
|
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,
|
textContent: label,
|
||||||
});
|
},
|
||||||
btn.addEventListener('click', handler);
|
);
|
||||||
|
btn.addEventListener("click", handler);
|
||||||
actions.appendChild(btn);
|
actions.appendChild(btn);
|
||||||
};
|
};
|
||||||
|
|
||||||
makeBtn('Connect', () => this._bulkConnect());
|
makeBtn("Connect", () => this._bulkConnect());
|
||||||
makeBtn('Disconnect', () => this._bulkDisconnect(), 'btn-outline');
|
makeBtn("Disconnect", () => this._bulkDisconnect(), "btn-outline");
|
||||||
makeBtn('Tags', () => this._bulkEditTags(), 'btn-outline');
|
makeBtn("Tags", () => this._bulkEditTags(), "btn-outline");
|
||||||
makeBtn('Move Group', () => this._bulkMoveGroup(), 'btn-outline');
|
makeBtn("Move Group", () => this._bulkMoveGroup(), "btn-outline");
|
||||||
makeBtn('Clear', () => this._clearSelection(), 'btn-ghost');
|
makeBtn("Clear", () => this._clearSelection(), "btn-ghost");
|
||||||
|
|
||||||
bar.appendChild(actions);
|
bar.appendChild(actions);
|
||||||
return bar;
|
return bar;
|
||||||
@@ -513,10 +559,11 @@ class Sidebar {
|
|||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
async _bulkDisconnect() {
|
async _bulkDisconnect() {
|
||||||
const sessions = this.app.state.getAllSessions()
|
const sessions = this.app.state
|
||||||
|
.getAllSessions()
|
||||||
.filter((s) => this.selectedProfiles.has(s.profileId));
|
.filter((s) => this.selectedProfiles.has(s.profileId));
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
this.app.notifications.info('No active sessions to disconnect');
|
this.app.notifications.info("No active sessions to disconnect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +585,7 @@ class Sidebar {
|
|||||||
for (const p of profiles) {
|
for (const p of profiles) {
|
||||||
const updated = JSON.parse(JSON.stringify(p));
|
const updated = JSON.parse(JSON.stringify(p));
|
||||||
const existing = updated.tags || [];
|
const existing = updated.tags || [];
|
||||||
if (result.mode === 'replace') {
|
if (result.mode === "replace") {
|
||||||
updated.tags = result.tags;
|
updated.tags = result.tags;
|
||||||
} else {
|
} else {
|
||||||
const merged = new Set([...existing, ...result.tags]);
|
const merged = new Set([...existing, ...result.tags]);
|
||||||
@@ -549,7 +596,7 @@ class Sidebar {
|
|||||||
const saved = await window.go.api.Service.UpdateProfile(updated);
|
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||||
this.app.state.updateProfile(saved);
|
this.app.state.updateProfile(saved);
|
||||||
} catch (err) {
|
} 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);
|
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||||
this.app.state.updateProfile(saved);
|
this.app.state.updateProfile(saved);
|
||||||
} catch (err) {
|
} 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 */
|
/** @private */
|
||||||
_confirmBulkDisconnect(count) {
|
_confirmBulkDisconnect(count) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = createElement('div', 'modal-backdrop');
|
const backdrop = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement('div', 'modal modal-sm');
|
const modal = createElement("div", "modal modal-sm");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">Disconnect Sessions</h3>
|
<h3 class="modal-title">Disconnect Sessions</h3>
|
||||||
@@ -590,7 +637,7 @@ class Sidebar {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -601,28 +648,36 @@ class Sidebar {
|
|||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
const close = (val) => {
|
const close = (val) => {
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(() => backdrop.remove(), 200);
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
resolve(val);
|
resolve(val);
|
||||||
};
|
};
|
||||||
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
modal
|
||||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
.addEventListener("click", () => close(false));
|
||||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) 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 */
|
/** @private */
|
||||||
_promptTagEdit() {
|
_promptTagEdit() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = createElement('div', 'modal-backdrop');
|
const backdrop = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement('div', 'modal modal-sm');
|
const modal = createElement("div", "modal modal-sm");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">Edit Tags</h3>
|
<h3 class="modal-title">Edit Tags</h3>
|
||||||
@@ -643,31 +698,39 @@ class Sidebar {
|
|||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = modal.querySelector('.tag-input');
|
const input = modal.querySelector(".tag-input");
|
||||||
const parseTags = () => {
|
const parseTags = () => {
|
||||||
const raw = (input.value || '').split(',');
|
const raw = (input.value || "").split(",");
|
||||||
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
|
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = (val) => {
|
const close = (val) => {
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(() => backdrop.remove(), 200);
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
resolve(val);
|
resolve(val);
|
||||||
};
|
};
|
||||||
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
modal
|
||||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.confirm-add').addEventListener('click', () => {
|
.addEventListener("click", () => close(null));
|
||||||
close({ mode: 'add', tags: parseTags() });
|
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', () => {
|
modal
|
||||||
close({ mode: 'replace', tags: parseTags() });
|
.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);
|
setTimeout(() => input.focus(), 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -675,8 +738,8 @@ class Sidebar {
|
|||||||
/** @private */
|
/** @private */
|
||||||
_promptGroupMove() {
|
_promptGroupMove() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const backdrop = createElement('div', 'modal-backdrop');
|
const backdrop = createElement("div", "modal-backdrop");
|
||||||
const modal = createElement('div', 'modal modal-sm');
|
const modal = createElement("div", "modal modal-sm");
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 class="modal-title">Move to Group</h3>
|
<h3 class="modal-title">Move to Group</h3>
|
||||||
@@ -699,21 +762,29 @@ class Sidebar {
|
|||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = modal.querySelector('.group-input');
|
const input = modal.querySelector(".group-input");
|
||||||
const close = (val) => {
|
const close = (val) => {
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(() => backdrop.remove(), 200);
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
resolve(val);
|
resolve(val);
|
||||||
};
|
};
|
||||||
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
modal
|
||||||
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.confirm-ok').addEventListener('click', () => close(input.value.trim()));
|
.addEventListener("click", () => close(null));
|
||||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) 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);
|
setTimeout(() => input.focus(), 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -732,11 +803,11 @@ class Sidebar {
|
|||||||
.map((p) => {
|
.map((p) => {
|
||||||
// Match against display name, host, tags, and protocol
|
// Match against display name, host, tags, and protocol
|
||||||
const fields = [
|
const fields = [
|
||||||
p.displayName || '',
|
p.displayName || "",
|
||||||
p.host || '',
|
p.host || "",
|
||||||
(p.tags || []).join(' '),
|
(p.tags || []).join(" "),
|
||||||
p.protocol || '',
|
p.protocol || "",
|
||||||
].join(' ');
|
].join(" ");
|
||||||
const result = fuzzyMatch(fields, this.searchQuery);
|
const result = fuzzyMatch(fields, this.searchQuery);
|
||||||
return { profile: p, ...result };
|
return { profile: p, ...result };
|
||||||
})
|
})
|
||||||
@@ -753,21 +824,20 @@ class Sidebar {
|
|||||||
*/
|
*/
|
||||||
_protocolLabel(protocol) {
|
_protocolLabel(protocol) {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case 'github':
|
case "github":
|
||||||
return 'GitHub';
|
return "GitHub";
|
||||||
case 'ssh':
|
case "ssh":
|
||||||
return 'SSH';
|
return "SSH";
|
||||||
case 'telnet':
|
case "telnet":
|
||||||
return 'TELNET';
|
return "TELNET";
|
||||||
case 'raw':
|
case "raw":
|
||||||
return 'RAW';
|
return "RAW";
|
||||||
default:
|
default:
|
||||||
return (protocol || '').toUpperCase() || 'UNKNOWN';
|
return (protocol || "").toUpperCase() || "UNKNOWN";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
window.NexusTerminal.Sidebar = Sidebar;
|
window.NexusTerminal.Sidebar = Sidebar;
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -214,6 +214,10 @@
|
|||||||
searchAddon,
|
searchAddon,
|
||||||
webLinksAddon,
|
webLinksAddon,
|
||||||
observer,
|
observer,
|
||||||
|
decoder:
|
||||||
|
typeof TextDecoder !== "undefined"
|
||||||
|
? new TextDecoder("utf-8")
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return term;
|
return term;
|
||||||
@@ -230,7 +234,16 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = atob(base64Data);
|
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);
|
entry.term.write(raw);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to decode session data:", err);
|
console.error("Failed to decode session data:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user