feat: enhance sidebar edit functionality and prevent text selection

- Added aria-label and type attributes to the edit button in the sidebar.
- Implemented a mechanism to prevent multiple edit triggers using requestAnimationFrame.
- Updated mousedown event handling to allow real controls while preventing text selection in WKWebView.

feat: add reconnectSession method to TabManager

- Introduced reconnectSession method to facilitate session reconnections by ID.

feat: implement disconnected overlay in terminal

- Added setDisconnectedOverlay method to show/hide a disconnected overlay for terminal sessions.
- Created _ensureDisconnectOverlay method to manage the overlay's existence and content.

feat: extend Service API with file-related functions

- Added FileExists and FileIsPrivateKey functions to check file existence and identify private keys.
- Introduced ListSSHKeys function to retrieve SSH private key paths from the user's .ssh directory.
- Implemented SFTPStat and SFTPUploadData functions for enhanced SFTP operations.

refactor: clean up code formatting and improve readability

- Adjusted code formatting for better readability across various files, including consistent use of line breaks and indentation.
This commit is contained in:
Andy Dixon
2026-04-22 14:56:52 +01:00
parent 2ce145ba0f
commit 8fa9fb5be5
20 changed files with 2066 additions and 1300 deletions
+8
View File
@@ -168,6 +168,14 @@
);
this.updateStatusBar();
const showDisconnectedOverlay =
payload.state === "disconnected" || payload.state === "error";
this.terminals.setDisconnectedOverlay(
payload.sessionID,
showDisconnectedOverlay,
payload.error,
);
if (payload.state === "error" && payload.error) {
this._handleSessionError(payload.sessionID, payload.error);
}
+215 -10
View File
@@ -82,6 +82,15 @@
/** @type {boolean} */
this._isNew = false;
/** @type {string[]} */
this._sshKeyOptions = [];
/** @type {boolean} */
this._sshKeyOptionsLoaded = false;
/** @type {boolean} */
this._sshKeyOptionsLoading = false;
}
// ── Public API ───────────────────────────────────────────────────
@@ -151,6 +160,7 @@
// ── Scrollable body ─────────────────────────────────────
const body = createElement("div", "drawer-body");
const sshKeyDatalist = this._buildSSHKeyDatalist();
// General section (common to all protocols)
body.appendChild(this._renderGeneralSection(profile));
@@ -159,6 +169,7 @@
switch (profile.protocol) {
case "ssh":
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
body.appendChild(sshKeyDatalist);
break;
case "telnet":
this._renderTelnetSections(profile).forEach((s) =>
@@ -240,6 +251,8 @@
// Focus the first input
const firstInput = drawer.querySelector("input, select, textarea");
if (firstInput) firstInput.focus();
this._loadSSHKeyOptions();
}
/**
@@ -377,6 +390,42 @@
return group;
}
_buildSSHKeyDatalist() {
const list = createElement("datalist", "", {
id: "ssh-key-path-options",
});
this._applySSHKeyOptionsToList(list);
return list;
}
_applySSHKeyOptionsToList(listEl) {
if (!listEl) return;
listEl.innerHTML = "";
for (const keyPath of this._sshKeyOptions) {
const opt = createElement("option", "", { value: keyPath });
listEl.appendChild(opt);
}
}
_loadSSHKeyOptions() {
if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return;
if (!window.go || !window.go.api || !window.go.api.Service) return;
this._sshKeyOptionsLoading = true;
window.go.api.Service.ListSSHKeys()
.then((keys) => {
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
this._sshKeyOptionsLoaded = true;
this._sshKeyOptionsLoading = false;
if (!this.drawer) return;
const listEl = this.drawer.querySelector("#ssh-key-path-options");
this._applySSHKeyOptionsToList(listEl);
})
.catch(() => {
this._sshKeyOptionsLoading = false;
});
}
/** @private */
_renderSSHSections(profile) {
const ssh = profile.ssh || {};
@@ -413,15 +462,7 @@
// Key & Passphrase
sections.push(
this._renderSection("Key & Passphrase", [
this._field(
"text",
"ssh.privateKeyPath",
"Private Key Path",
ssh.privateKeyPath,
{
placeholder: "~/.ssh/id_ed25519",
},
),
this._renderSSHKeyPathField(ssh.privateKeyPath),
this._field(
"select",
"ssh.keySecretMode",
@@ -1522,6 +1563,10 @@
group.appendChild(input);
if (opts.datalistId && input && input.tagName === "INPUT") {
input.setAttribute("list", opts.datalistId);
}
// Help text
if (opts.help) {
group.appendChild(
@@ -1532,6 +1577,64 @@
return group;
}
_renderSSHKeyPathField(value) {
const group = createElement("div", "form-group");
const labelEl = createElement("label", "form-label", {
textContent: "Private Key Path",
for: "field-ssh.privateKeyPath",
});
group.appendChild(labelEl);
const row = createElement("div", "key-path-row");
const input = createElement("input", "form-input", {
type: "text",
id: "field-ssh.privateKeyPath",
name: "ssh.privateKeyPath",
value: value || "",
placeholder: "/Users/username/.ssh/id_ed25519",
});
input.setAttribute("list", "ssh-key-path-options");
row.appendChild(input);
const browseBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "Browse",
type: "button",
});
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
browseBtn.addEventListener("click", () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
.then((path) => {
if (path) input.value = path;
})
.catch((err) => {
this.app.notifications.error(
"Failed to open file dialog: " + (err.message || err),
);
});
});
row.appendChild(browseBtn);
group.appendChild(row);
const error = createElement("span", "form-error");
error.style.display = "none";
group.appendChild(error);
const success = createElement("span", "form-success");
success.style.display = "none";
group.appendChild(success);
this._attachKeyPathValidation(input, error, success);
group.appendChild(
createElement("span", "form-hint", {
textContent: "Select a key from your .ssh folder or type a full path",
}),
);
return group;
}
/**
* Renders the jump-hosts list editor.
* @param {Array<Object>} jumpHosts
@@ -1551,15 +1654,41 @@
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || "")}" placeholder="User" />
</div>
<div class="jump-host-fields">
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Private key path (optional)" style="flex:1" />
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Full key path (optional)" list="ssh-key-path-options" style="flex:1" />
<button class="btn btn-sm btn-outline jump-host-browse" type="button" title="Browse">Browse</button>
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">&times;</button>
</div>
`;
const keyInput = row.querySelector(`input[name="jumpHost-${i}-privateKeyPath"]`);
const browseBtn = row.querySelector(".jump-host-browse");
const error = createElement("span", "form-error");
error.style.display = "none";
const success = createElement("span", "form-success");
success.style.display = "none";
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
browseBtn.addEventListener("click", () => {
if (!window.go || !window.go.api || !window.go.api.Service) return;
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
.then((path) => {
if (path) keyInput.value = path;
this._validateKeyPath(keyInput, error);
})
.catch((err) => {
this.app.notifications.error(
"Failed to open file dialog: " + (err.message || err),
);
});
});
this._attachKeyPathValidation(keyInput, error, success);
row.querySelector(".jump-host-remove").addEventListener("click", () => {
jumpHosts.splice(i, 1);
this.render(this._profile, this._isNew);
});
wrapper.appendChild(row);
wrapper.appendChild(error);
wrapper.appendChild(success);
}
const addBtn = createElement("button", "btn btn-sm btn-outline", {
@@ -1581,6 +1710,82 @@
return wrapper;
}
_attachKeyPathValidation(input, errorEl, successEl) {
if (!input || !errorEl) return;
input.addEventListener("blur", () => this._validateKeyPath(input, errorEl, successEl));
input.addEventListener("input", () => {
errorEl.style.display = "none";
errorEl.textContent = "";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
});
}
_validateKeyPath(input, errorEl, successEl) {
const value = (input.value || "").trim();
if (!value) {
errorEl.style.display = "none";
errorEl.textContent = "";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
return;
}
if (value.startsWith("~")) {
errorEl.textContent = "Please use a full path (no ~).";
errorEl.style.display = "block";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
return;
}
if (!window.go || !window.go.api || !window.go.api.Service) {
return;
}
window.go.api.Service.FileExists(value)
.then((exists) => {
if (!exists) {
errorEl.textContent = "Key file not found.";
errorEl.style.display = "block";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
return;
}
return window.go.api.Service.FileIsPrivateKey(value)
.then((isKey) => {
if (!isKey) {
errorEl.textContent = "File is not a recognised private key.";
errorEl.style.display = "block";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
return;
}
errorEl.style.display = "none";
errorEl.textContent = "";
if (successEl) {
successEl.textContent = "Key verified.";
successEl.style.display = "block";
}
});
})
.catch(() => {
errorEl.textContent = "Could not verify this path.";
errorEl.style.display = "block";
if (successEl) {
successEl.style.display = "none";
successEl.textContent = "";
}
});
}
/**
* Renders the port-forwarding rules list editor.
* @param {Array<Object>} portForwards
+630 -485
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -253,11 +253,22 @@
const editBtn = createElement("button", "sidebar-item-edit", {
textContent: "✎",
title: "Edit profile",
type: "button",
"aria-label": "Edit profile",
});
editBtn.addEventListener("click", (e) => {
let editTriggered = false;
const triggerEdit = (e) => {
e.preventDefault();
e.stopPropagation();
if (editTriggered) return;
editTriggered = true;
requestAnimationFrame(() => {
editTriggered = false;
});
this.app.profileEditor.openEdit(profile.id);
});
};
editBtn.addEventListener("mousedown", triggerEdit);
editBtn.addEventListener("click", triggerEdit);
item.appendChild(editBtn);
// Favourite star
@@ -268,8 +279,11 @@
item.appendChild(star);
}
// Prevent mousedown text selection in WKWebView
item.addEventListener("mousedown", (e) => e.preventDefault());
// Prevent mousedown text selection in WKWebView, but allow real controls.
item.addEventListener("mousedown", (e) => {
if (e.target.closest("button, input, select, textarea")) return;
e.preventDefault();
});
// Click → connect / multi-select
item.addEventListener("click", (e) => {
+8
View File
@@ -120,6 +120,14 @@ class TabManager {
}
}
/**
* Reconnects a session by ID.
* @param {string} sessionId
*/
reconnectSession(sessionId) {
return this._reconnect(sessionId);
}
/**
* Disconnects and closes a tab by session ID.
* @param {string} sessionId
+145 -35
View File
@@ -104,7 +104,10 @@
// Drag-and-drop file handling
container.addEventListener("dragover", (e) => {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files")) {
if (
e.dataTransfer &&
Array.from(e.dataTransfer.types || []).includes("Files")
) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
@@ -230,9 +233,7 @@
webLinksAddon,
observer,
decoder:
typeof TextDecoder !== "undefined"
? new TextDecoder("utf-8")
: null,
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
});
return term;
@@ -327,6 +328,28 @@
}
}
/**
* Shows or hides the disconnected overlay for a session panel.
* @param {string} sessionId
* @param {boolean} isDisconnected
* @param {string} [error]
*/
setDisconnectedOverlay(sessionId, isDisconnected, error) {
const panel = document.getElementById(`panel-${sessionId}`);
if (!panel) return;
const overlay = this._ensureDisconnectOverlay(panel, sessionId);
if (!overlay) return;
const message = overlay.querySelector(".terminal-disconnect-message");
if (message) {
message.textContent = error ? error : "Connection lost.";
}
panel.classList.toggle("is-disconnected", isDisconnected);
overlay.classList.toggle("is-visible", isDisconnected);
}
/**
* Searches within a terminal using the search addon.
* @param {string} sessionId
@@ -416,6 +439,44 @@
// ── Private helpers ──────────────────────────────────────────────
/**
* Ensures the disconnect overlay exists for a session panel.
* @param {HTMLElement} panel
* @param {string} sessionId
* @returns {HTMLElement|null}
* @private
*/
_ensureDisconnectOverlay(panel, sessionId) {
let overlay = panel.querySelector(".terminal-disconnect-overlay");
if (overlay) return overlay;
const { createElement } = window.NexusTerminal;
if (!createElement) return null;
overlay = createElement("div", "terminal-disconnect-overlay");
const content = createElement("div", "terminal-disconnect-content");
const title = createElement("div", "terminal-disconnect-title", {
textContent: "DISCONNECTED",
});
const message = createElement("div", "terminal-disconnect-message", {
textContent: "Connection lost.",
});
const button = createElement("button", "btn btn-sm btn-primary", {
textContent: "Reconnect",
});
button.addEventListener("click", () => {
this.app.tabs.reconnectSession(sessionId);
});
content.appendChild(title);
content.appendChild(message);
content.appendChild(button);
overlay.appendChild(content);
panel.appendChild(overlay);
return overlay;
}
/**
* Shows a right-click context menu for a terminal.
* @param {string} sessionId
@@ -540,7 +601,9 @@
}
if (choice === "upload") {
if (!canUpload) {
this.app.notifications.error("Upload is only available for SSH sessions.");
this.app.notifications.error(
"Upload is only available for SSH sessions.",
);
return;
}
this._uploadDroppedFiles(sessionId, files);
@@ -555,8 +618,12 @@
const modal = createElement("div", "modal modal-sm");
const count = files.length;
const first = files[0];
const sizeLabel = first && typeof first.size === "number" ? formatBytes(first.size) : "";
const title = count === 1 ? (first ? first.name : "File") : count + " files";
const sizeLabel =
first && typeof first.size === "number"
? formatBytes(first.size)
: "";
const title =
count === 1 ? (first ? first.name : "File") : count + " files";
modal.innerHTML = `
<div class="modal-header">
@@ -648,7 +715,9 @@
if (!remoteDir) return;
if (files.length > 1) {
this.app.notifications.info("Uploading " + files.length + " files...");
this.app.notifications.info(
"Uploading " + files.length + " files...",
);
}
for (let i = 0; i < files.length; i += 1) {
@@ -664,7 +733,9 @@
);
} catch (err) {
const msg = err && err.message ? err.message : String(err);
this.app.notifications.error("Upload failed for " + file.name + ": " + msg);
this.app.notifications.error(
"Upload failed for " + file.name + ": " + msg,
);
return;
}
}
@@ -738,7 +809,7 @@
});
const listEl = modal.querySelector(".sftp-picker-list");
const breadcrumbEl = modal.querySelector("[data-role=\"breadcrumb\"]");
const breadcrumbEl = modal.querySelector('[data-role="breadcrumb"]');
const pathLabel = modal.querySelector(".sftp-picker-path");
const upBtn = modal.querySelector(".sftp-picker-up");
const refreshBtn = modal.querySelector(".sftp-picker-refresh");
@@ -771,7 +842,9 @@
modal
.querySelector(".prompt-cancel")
.addEventListener("click", () => close(null));
useBtn.addEventListener("click", () => close(state.selected || state.cwd));
useBtn.addEventListener("click", () =>
close(state.selected || state.cwd),
);
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null);
});
@@ -782,7 +855,9 @@
for (let i = 0; i < segments.length; i += 1) {
if (i > 0) {
breadcrumbEl.appendChild(
createElement("span", "sftp-breadcrumb-sep", { textContent: ">" }),
createElement("span", "sftp-breadcrumb-sep", {
textContent: ">",
}),
);
}
const seg = segments[i];
@@ -797,9 +872,13 @@
const updateStatus = () => {
if (state.selected) {
statusLabel.textContent = state.selectedWritable ? "" : "No write permission";
statusLabel.textContent = state.selectedWritable
? ""
: "No write permission";
} else {
statusLabel.textContent = state.cwdWritable ? "" : "Current folder is read only";
statusLabel.textContent = state.cwdWritable
? ""
: "Current folder is read only";
}
};
@@ -807,7 +886,9 @@
listEl.querySelectorAll(".sftp-file-row").forEach((el) => {
el.classList.remove("is-selected");
});
const row = listEl.querySelector(`.sftp-file-row[data-index="${index}"]`);
const row = listEl.querySelector(
`.sftp-file-row[data-index="${index}"]`,
);
if (row) row.classList.add("is-selected");
state.selected = entry.path;
state.selectedWritable = entry.canWrite !== false;
@@ -821,7 +902,11 @@
const renderList = () => {
listEl.innerHTML = "";
const basePath = state.selected || state.cwd || "";
const suffix = state.selected ? "" : (state.cwdWritable ? "" : " (read only)");
const suffix = state.selected
? ""
: state.cwdWritable
? ""
: " (read only)";
pathLabel.textContent = basePath + suffix;
if (state.cwd && state.cwd !== "/") {
@@ -831,18 +916,23 @@
path: this._parentPath(state.cwd),
});
parentRow.classList.add("is-parent");
parentRow.addEventListener("dblclick", () => loadDir(parentRow.dataset.path));
parentRow.addEventListener("dblclick", () =>
loadDir(parentRow.dataset.path),
);
listEl.appendChild(parentRow);
}
const filtered = state.filter
? state.entries.filter((entry) => entry.name.toLowerCase().includes(state.filter))
? state.entries.filter((entry) =>
entry.name.toLowerCase().includes(state.filter),
)
: state.entries;
state.visibleEntries = filtered;
if (!filtered.length) {
const empty = createElement("div", "sftp-state");
empty.innerHTML = "<div class=\"sftp-state-icon\">D</div><div class=\"sftp-state-msg\">No folders found</div>";
empty.innerHTML =
'<div class="sftp-state-icon">D</div><div class="sftp-state-msg">No folders found</div>';
listEl.appendChild(empty);
state.cursorIndex = -1;
updateStatus();
@@ -873,7 +963,8 @@
const renderLoading = () => {
listEl.innerHTML = "";
const s = createElement("div", "sftp-state");
s.innerHTML = "<div class=\"sftp-spinner\"></div><div class=\"sftp-state-msg\">Loading...</div>";
s.innerHTML =
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading...</div>';
listEl.appendChild(s);
};
@@ -881,11 +972,14 @@
listEl.innerHTML = "";
const s = createElement("div", "sftp-state");
s.innerHTML =
"<div class=\"sftp-state-icon\">!</div>" +
"<div class=\"sftp-state-msg\">" + escapeHtml(msg) + "</div>" +
"<button class=\"btn btn-sm btn-secondary sftp-state-retry\">Retry</button>";
'<div class="sftp-state-icon">!</div>' +
'<div class="sftp-state-msg">' +
escapeHtml(msg) +
"</div>" +
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
const retryBtn = s.querySelector(".sftp-state-retry");
if (retryBtn) retryBtn.addEventListener("click", () => loadDir(state.cwd));
if (retryBtn)
retryBtn.addEventListener("click", () => loadDir(state.cwd));
listEl.appendChild(s);
};
@@ -894,16 +988,27 @@
state.loading = true;
renderLoading();
try {
const targetPath = path || (await window.go.api.Service.SFTPPwd(sessionId));
const files = await window.go.api.Service.SFTPList(sessionId, targetPath);
const stat = await window.go.api.Service.SFTPStat(sessionId, targetPath);
const targetPath =
path || (await window.go.api.Service.SFTPPwd(sessionId));
const files = await window.go.api.Service.SFTPList(
sessionId,
targetPath,
);
const stat = await window.go.api.Service.SFTPStat(
sessionId,
targetPath,
);
state.cwd = targetPath;
state.selected = "";
state.selectedWritable = true;
state.cwdWritable = stat && this._modeCanWrite(stat.mode);
state.entries = (files || []).filter((e) => e.isDir).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
state.entries = (files || [])
.filter((e) => e.isDir)
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: "base",
}),
);
state.entries = state.entries.map((entry) => {
const canRead = this._modeCanRead(entry.mode);
const canWrite = this._modeCanWrite(entry.mode);
@@ -972,9 +1077,10 @@
}
});
const initial = this.app.sftp && typeof this.app.sftp.getCwd === "function"
? this.app.sftp.getCwd(sessionId)
: "";
const initial =
this.app.sftp && typeof this.app.sftp.getCwd === "function"
? this.app.sftp.getCwd(sessionId)
: "";
loadDir(initial || "");
});
}
@@ -986,7 +1092,9 @@
dataset.index = String(index);
}
const row = createElement("div", "sftp-file-row", { dataset });
const icon = createElement("span", "sftp-file-icon is-dir", { textContent: "DIR" });
const icon = createElement("span", "sftp-file-icon is-dir", {
textContent: "DIR",
});
const name = createElement("span", "sftp-file-name", {
textContent: entry.name || entry.path || "",
title: entry.name || entry.path || "",
@@ -997,7 +1105,9 @@
} else if (entry.canWrite === false) {
metaLabel = "READ ONLY";
}
const meta = createElement("span", "sftp-file-meta", { textContent: metaLabel });
const meta = createElement("span", "sftp-file-meta", {
textContent: metaLabel,
});
row.appendChild(icon);
row.appendChild(name);
row.appendChild(meta);