Files
nexus/frontend/src/js/sftp.js
T
2026-06-16 15:43:25 +01:00

1241 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
/**
* Nexus Terminal — SFTP File Browser
*
* Provides a right-side panel for browsing, uploading, downloading,
* renaming and deleting remote files alongside the terminal.
*/
const {
createElement,
escapeHtml,
formatBytes,
relativeTime,
dismissContextMenus,
} = window.NexusTerminal;
/* ── Helpers ──────────────────────────────────────────────────────── */
function pathJoin(base, name) {
if (base === "/") return "/" + name;
return base.replace(/\/+$/, "") + "/" + name;
}
function pathSegments(p) {
const parts = p.split("/").filter(Boolean);
var segs = [{ label: "/", path: "/" }];
var acc = "";
for (var i = 0; i < parts.length; i++) {
acc += "/" + parts[i];
segs.push({ label: parts[i], path: acc });
}
return segs;
}
function parentPath(p) {
if (p === "/") return "/";
var trimmed = p.replace(/\/+$/, "");
var idx = trimmed.lastIndexOf("/");
return idx <= 0 ? "/" : trimmed.substring(0, idx);
}
function fileIcon(entry) {
if (entry.isDir) return "📁";
var ext = (entry.name.split(".").pop() || "").toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "svg":
case "webp":
return "🖼";
case "mp3":
case "wav":
case "ogg":
case "flac":
return "🎵";
case "mp4":
case "avi":
case "mkv":
case "mov":
return "🎬";
case "zip":
case "tar":
case "gz":
case "bz2":
case "xz":
case "7z":
return "📦";
case "sh":
case "bash":
case "zsh":
return "⚙";
case "py":
case "js":
case "ts":
case "go":
case "rs":
case "c":
case "h":
case "cpp":
case "java":
return "◇";
case "md":
case "txt":
case "log":
case "csv":
return "📝";
case "json":
case "yaml":
case "yml":
case "toml":
case "xml":
return "⚙";
case "key":
case "pem":
case "crt":
case "pub":
return "🔑";
default:
return "📄";
}
}
function modeString(mode) {
if (!mode && mode !== 0) return "";
var s = "";
var perms = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
s += perms[(mode >> 6) & 7];
s += perms[(mode >> 3) & 7];
s += perms[mode & 7];
return s;
}
/* ── SFTPBrowser class ────────────────────────────────────────────── */
class SFTPBrowser {
/**
* @param {App} app — the application instance
*/
constructor(app) {
this.app = app;
/** @type {Map<string, {el: HTMLElement, handle: HTMLElement, cwd: string, files: Array, selected: string|null, loading: boolean}>} */
this.panels = new Map();
}
/* ── Public API ───────────────────────────────────────────────── */
/**
* Opens (or focuses) the SFTP panel for a session.
* @param {string} sessionId
*/
async open(sessionId) {
if (this.panels.has(sessionId)) return;
var sessionPanel = document.getElementById("panel-" + sessionId);
if (!sessionPanel) {
this.app.notifications.error(
"Cannot open file browser: session panel not found.",
);
return;
}
var state = {
el: null,
handle: null,
cwd: "/",
files: [],
selected: null,
loading: false,
};
// Build the panel DOM
var panel = createElement("div", "sftp-panel");
var handle = createElement("div", "sftp-resize-handle");
panel.appendChild(handle);
state.el = panel;
state.handle = handle;
// Toolbar
var toolbar = this._buildToolbar(sessionId, state);
panel.appendChild(toolbar);
// File list container
var fileList = createElement("div", "sftp-file-list");
panel.appendChild(fileList);
// Context action bar (hidden initially)
var ctxBar = createElement("div", "sftp-context-bar");
ctxBar.style.display = "none";
panel.appendChild(ctxBar);
this.panels.set(sessionId, state);
// Append the overlay panel into the session panel
sessionPanel.appendChild(panel);
this._setupResize(sessionId, state);
// Fetch initial directory
await this._navigate(sessionId, null);
// Refit terminal after layout change
this._refitTerminal(sessionId);
}
/**
* Closes the SFTP panel for a session.
* @param {string} sessionId
*/
close(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
state.el.remove();
this.panels.delete(sessionId);
this._refitTerminal(sessionId);
}
/**
* Toggles the SFTP panel for a session.
* @param {string} sessionId
*/
toggle(sessionId) {
if (this.panels.has(sessionId)) {
this.close(sessionId);
} else {
this.open(sessionId);
}
}
/**
* Returns the current directory for an open SFTP panel, or an empty string.
* @param {string} sessionId
* @returns {string}
*/
getCwd(sessionId) {
var state = this.panels.get(sessionId);
return state ? state.cwd : "";
}
/* ── Toolbar ──────────────────────────────────────────────────── */
_buildToolbar(sessionId, state) {
var toolbar = createElement("div", "sftp-toolbar");
var breadcrumb = createElement("div", "sftp-breadcrumb");
breadcrumb.dataset.role = "breadcrumb";
toolbar.appendChild(breadcrumb);
var actions = createElement("div", "sftp-toolbar-actions");
var goToBtn = this._toolbarBtn("⤳", "Go to path", "");
goToBtn.addEventListener("click", () => this._promptGoToPath(sessionId));
actions.appendChild(goToBtn);
var bookmarkBtn = this._toolbarBtn("★", "Bookmarks", "");
bookmarkBtn.addEventListener("click", () =>
this._showBookmarks(sessionId),
);
actions.appendChild(bookmarkBtn);
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
actions.appendChild(mkdirBtn);
var uploadBtn = this._toolbarBtn("↑", "Upload file", "is-accent");
uploadBtn.addEventListener("click", () => this._upload(sessionId));
actions.appendChild(uploadBtn);
var refreshBtn = this._toolbarBtn("⟳", "Refresh");
refreshBtn.addEventListener("click", () => this._refresh(sessionId));
actions.appendChild(refreshBtn);
var closeBtn = this._toolbarBtn("✕", "Close", "is-close");
closeBtn.addEventListener("click", () => this.close(sessionId));
actions.appendChild(closeBtn);
toolbar.appendChild(actions);
return toolbar;
}
_toolbarBtn(label, title, extraClass) {
var cls = "sftp-toolbar-btn" + (extraClass ? " " + extraClass : "");
return createElement("button", cls, { textContent: label, title: title });
}
/* ── Navigation ───────────────────────────────────────────────── */
/**
* Navigates to a directory path. If path is null, fetches pwd first.
*/
async _navigate(sessionId, path) {
var state = this.panels.get(sessionId);
if (!state || state.loading) return;
state.loading = true;
state.selected = null;
this._renderLoading(sessionId);
try {
if (path === null) {
path = await window.NexusTerminal.wailsService.SFTPPwd(sessionId);
}
var files = await window.NexusTerminal.wailsService.SFTPList(sessionId, path);
state.cwd = path;
state.files = this._sortFiles(files || []);
state.loading = false;
this._renderBreadcrumb(sessionId);
this._renderFiles(sessionId);
} catch (err) {
state.loading = false;
this._renderError(sessionId, err.message || String(err));
}
}
async _refresh(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
await this._navigate(sessionId, state.cwd);
}
_sortFiles(files) {
return files.slice().sort(function (a, b) {
// Directories first
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
/* ── Rendering ────────────────────────────────────────────────── */
_getFileList(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return null;
return state.el.querySelector(".sftp-file-list");
}
_getCtxBar(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return null;
return state.el.querySelector(".sftp-context-bar");
}
_renderBreadcrumb(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var container = state.el.querySelector('[data-role="breadcrumb"]');
container.innerHTML = "";
var segs = pathSegments(state.cwd);
var self = this;
for (var i = 0; i < segs.length; i++) {
if (i > 0) {
container.appendChild(
createElement("span", "sftp-breadcrumb-sep", { textContent: "›" }),
);
}
var btn = createElement("button", "sftp-breadcrumb-segment", {
textContent: segs[i].label,
title: segs[i].path,
});
(function (p) {
btn.addEventListener("click", function () {
self._navigate(sessionId, p);
});
})(segs[i].path);
container.appendChild(btn);
}
}
_renderFiles(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var list = this._getFileList(sessionId);
list.innerHTML = "";
// Hide context bar
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
// Parent directory row if not at root
if (state.cwd !== "/") {
var parentRow = this._buildFileRow(
{
name: "..",
isDir: true,
path: parentPath(state.cwd),
size: 0,
modifiedAt: 0,
},
sessionId,
true,
);
parentRow.classList.add("is-parent");
list.appendChild(parentRow);
}
if (state.files.length === 0) {
var empty = createElement("div", "sftp-state");
empty.innerHTML =
'<div class="sftp-state-icon">📂</div><div class="sftp-state-msg">Empty directory</div>';
list.appendChild(empty);
return;
}
var self = this;
for (var i = 0; i < state.files.length; i++) {
list.appendChild(this._buildFileRow(state.files[i], sessionId, false));
}
}
_buildFileRow(entry, sessionId, isParent) {
var row = createElement("div", "sftp-file-row");
var self = this;
// Icon
var icon = createElement(
"span",
"sftp-file-icon" + (entry.isDir ? " is-dir" : ""),
);
icon.textContent = isParent ? "↩" : fileIcon(entry);
row.appendChild(icon);
// Name
var name = createElement("span", "sftp-file-name", {
textContent: entry.name,
title: entry.name,
});
row.appendChild(name);
// Meta (size or date)
var meta = createElement("span", "sftp-file-meta");
if (!isParent && !entry.isDir) {
meta.textContent = formatBytes(entry.size || 0);
meta.title = entry.modifiedAt
? new Date(entry.modifiedAt).toLocaleString()
: "";
} else if (!isParent && entry.isDir) {
meta.textContent = "";
}
row.appendChild(meta);
// Double-click: navigate into directory
if (entry.isDir) {
row.addEventListener("dblclick", function () {
var target = isParent
? parentPath(self.panels.get(sessionId).cwd)
: entry.path;
self._navigate(sessionId, target);
});
}
// Single click: select (files only, or directories too for context actions)
if (!isParent) {
row.addEventListener("click", function (e) {
e.stopPropagation();
self._selectFile(sessionId, entry, row, e);
});
}
return row;
}
_renderLoading(sessionId) {
var list = this._getFileList(sessionId);
if (!list) return;
list.innerHTML = "";
var s = createElement("div", "sftp-state");
s.innerHTML =
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading…</div>';
list.appendChild(s);
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
}
_renderError(sessionId, msg) {
var list = this._getFileList(sessionId);
if (!list) return;
list.innerHTML = "";
var 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>';
var self = this;
var retryBtn = s.querySelector(".sftp-state-retry");
if (retryBtn) {
retryBtn.addEventListener("click", function () {
self._refresh(sessionId);
});
}
list.appendChild(s);
}
/* ── Selection & context actions ──────────────────────────────── */
_selectFile(sessionId, entry, rowEl, event) {
var state = this.panels.get(sessionId);
if (!state) return;
// Initialise multi-select set if needed
if (!state.selectedFiles) state.selectedFiles = new Map();
var multiKey = event && (event.metaKey || event.ctrlKey);
if (multiKey) {
// Toggle this entry in the multi-selection
if (state.selectedFiles.has(entry.path)) {
state.selectedFiles.delete(entry.path);
rowEl.classList.remove("is-selected");
} else {
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
} else {
// Single click without modifier — clear all, select this one
state.el
.querySelectorAll(".sftp-file-row.is-selected")
.forEach(function (r) {
r.classList.remove("is-selected");
});
// If clicking the same entry (and it was the only selection), deselect
if (
state.selectedFiles.size === 1 &&
state.selectedFiles.has(entry.path)
) {
state.selectedFiles.clear();
state.selected = null;
var ctxBar = this._getCtxBar(sessionId);
if (ctxBar) ctxBar.style.display = "none";
return;
}
state.selectedFiles.clear();
state.selectedFiles.set(entry.path, entry);
rowEl.classList.add("is-selected");
}
state.selected = state.selectedFiles.size > 0 ? entry.path : null;
this._showContextBar(sessionId, null);
}
_showContextBar(sessionId, _unused) {
var state = this.panels.get(sessionId);
if (!state) return;
var ctxBar = this._getCtxBar(sessionId);
if (!ctxBar) return;
if (!state.selectedFiles || state.selectedFiles.size === 0) {
ctxBar.style.display = "none";
return;
}
ctxBar.innerHTML = "";
ctxBar.style.display = "flex";
var self = this;
var entries = Array.from(state.selectedFiles.values());
var count = entries.length;
var isSingle = count === 1;
var entry = entries[0];
var label = createElement("span", "sftp-ctx-label", {
textContent: isSingle ? entry.name : count + " items selected",
title: isSingle
? entry.path
: entries
.map(function (e) {
return e.name;
})
.join(", "),
});
ctxBar.appendChild(label);
// Download button — only for files (single or batch)
var downloadableFiles = entries.filter(function (e) {
return !e.isDir;
});
if (downloadableFiles.length > 0) {
var dlLabel =
downloadableFiles.length === 1
? "↓ Download"
: "↓ Download (" + downloadableFiles.length + ")";
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
textContent: dlLabel,
});
dlBtn.addEventListener("click", function () {
self._downloadBatch(sessionId, downloadableFiles);
});
ctxBar.appendChild(dlBtn);
}
if (isSingle) {
var renameBtn = createElement("button", "sftp-ctx-btn", {
textContent: "✎ Rename",
});
renameBtn.addEventListener("click", function () {
self._promptRename(sessionId, entry);
});
ctxBar.appendChild(renameBtn);
}
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
textContent: delLabel,
});
delBtn.addEventListener("click", function () {
if (isSingle) {
self._confirmDelete(sessionId, entry);
} else {
self._confirmDeleteBatch(sessionId, entries);
}
});
ctxBar.appendChild(delBtn);
}
/* ── File operations ──────────────────────────────────────────── */
async _download(sessionId, filePath) {
try {
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, filePath);
this.app.notifications.success("Download started");
} catch (err) {
this.app.notifications.error(
"Download failed: " + (err.message || err),
);
}
}
async _upload(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
try {
await window.NexusTerminal.wailsService.SFTPUpload(sessionId, state.cwd);
this.app.notifications.success("Upload complete");
await this._refresh(sessionId);
} catch (err) {
// User may have cancelled the dialog — only show error if meaningful
var msg = err.message || String(err);
if (msg && msg.toLowerCase().indexOf("cancel") === -1) {
this.app.notifications.error("Upload failed: " + msg);
}
}
}
async _mkdir(sessionId, name) {
var state = this.panels.get(sessionId);
if (!state) return;
var fullPath = pathJoin(state.cwd, name);
try {
await window.NexusTerminal.wailsService.SFTPMkdir(sessionId, fullPath);
this.app.notifications.success("Directory created");
await this._refresh(sessionId);
} catch (err) {
this.app.notifications.error("mkdir failed: " + (err.message || err));
}
}
async _rename(sessionId, oldPath, newName) {
var state = this.panels.get(sessionId);
if (!state) return;
var newPath = pathJoin(parentPath(oldPath), newName);
try {
await window.NexusTerminal.wailsService.SFTPRename(sessionId, oldPath, newPath);
this.app.notifications.success("Renamed");
await this._refresh(sessionId);
} catch (err) {
this.app.notifications.error("Rename failed: " + (err.message || err));
}
}
async _delete(sessionId, entry) {
try {
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entry.path);
this.app.notifications.success(
(entry.isDir ? "Directory" : "File") + " deleted",
);
await this._refresh(sessionId);
} catch (err) {
this.app.notifications.error("Delete failed: " + (err.message || err));
}
}
async _downloadBatch(sessionId, files) {
this.app.notifications.info("Downloading " + files.length + " files…");
var failed = 0;
for (var i = 0; i < files.length; i++) {
try {
await window.NexusTerminal.wailsService.SFTPDownload(sessionId, files[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Download failed: " + files[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success(
"All " + files.length + " files downloaded",
);
}
}
_confirmDeleteBatch(sessionId, entries) {
this._showConfirmModal({
title: "Delete " + entries.length + " Items",
message:
"Permanently delete <strong>" +
entries.length +
" selected items</strong>?",
confirmLabel: "Delete All",
isDanger: true,
onConfirm: function () {
this._deleteBatch(sessionId, entries);
}.bind(this),
});
}
async _deleteBatch(sessionId, entries) {
var failed = 0;
for (var i = 0; i < entries.length; i++) {
try {
await window.NexusTerminal.wailsService.SFTPRemove(sessionId, entries[i].path);
} catch (err) {
failed++;
this.app.notifications.error(
"Delete failed: " + entries[i].name + " — " + (err.message || err),
);
}
}
if (failed === 0) {
this.app.notifications.success("Deleted " + entries.length + " items");
}
await this._refresh(sessionId);
}
/* ── Modal dialogs (no prompt/confirm/alert) ──────────────────── */
_promptMkdir(sessionId) {
this._showInputModal({
title: "New Folder",
placeholder: "Folder name",
confirmLabel: "Create",
onConfirm: function (value) {
if (value.trim()) this._mkdir(sessionId, value.trim());
}.bind(this),
});
}
_promptRename(sessionId, entry) {
this._showInputModal({
title: "Rename",
placeholder: "New name",
initialValue: entry.name,
confirmLabel: "Rename",
onConfirm: function (value) {
var v = value.trim();
if (v && v !== entry.name) this._rename(sessionId, entry.path, v);
}.bind(this),
});
}
_confirmDelete(sessionId, entry) {
this._showConfirmModal({
title: "Delete " + (entry.isDir ? "Folder" : "File"),
message:
"Permanently delete <strong>" + escapeHtml(entry.name) + "</strong>?",
confirmLabel: "Delete",
isDanger: true,
onConfirm: function () {
this._delete(sessionId, entry);
}.bind(this),
});
}
/**
* Displays a modal with a text input field.
*/
_showInputModal(opts) {
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm");
modal.innerHTML =
'<div class="modal-header">' +
'<span class="modal-title">' +
escapeHtml(opts.title) +
"</span>" +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
"</div>" +
'<div class="modal-body">' +
'<input class="sftp-inline-input" type="text" placeholder="' +
escapeHtml(opts.placeholder || "") +
'"' +
(opts.initialValue
? ' value="' + escapeHtml(opts.initialValue) + '"'
: "") +
" />" +
"</div>" +
'<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
'<button class="btn btn-sm btn-primary sftp-modal-confirm">' +
escapeHtml(opts.confirmLabel || "OK") +
"</button>" +
"</div>";
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
var input = modal.querySelector(".sftp-inline-input");
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
input.focus();
input.select();
});
var closed = false;
function close(confirmed) {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(function () {
backdrop.remove();
}, 200);
if (confirmed && opts.onConfirm) {
opts.onConfirm(input.value);
}
}
modal
.querySelector(".modal-close")
.addEventListener("click", function () {
close(false);
});
modal
.querySelector(".sftp-modal-cancel")
.addEventListener("click", function () {
close(false);
});
modal
.querySelector(".sftp-modal-confirm")
.addEventListener("click", function () {
close(true);
});
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close(false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") close(true);
if (e.key === "Escape") close(false);
});
}
/**
* Displays a confirmation modal (no native confirm).
*/
_showConfirmModal(opts) {
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm confirm-dialog");
modal.innerHTML =
'<div class="modal-header">' +
'<span class="modal-title">' +
escapeHtml(opts.title) +
"</span>" +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
"</div>" +
'<div class="modal-body">' +
"<p>" +
(opts.message || "") +
"</p>" +
"</div>" +
'<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
'<button class="btn btn-sm ' +
(opts.isDanger ? "btn-danger-filled" : "btn-primary") +
' sftp-modal-confirm">' +
escapeHtml(opts.confirmLabel || "Confirm") +
"</button>" +
"</div>";
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
var closed = false;
function close(confirmed) {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(function () {
backdrop.remove();
}, 200);
if (confirmed && opts.onConfirm) {
opts.onConfirm();
}
}
modal
.querySelector(".modal-close")
.addEventListener("click", function () {
close(false);
});
modal
.querySelector(".sftp-modal-cancel")
.addEventListener("click", function () {
close(false);
});
modal
.querySelector(".sftp-modal-confirm")
.addEventListener("click", function () {
close(true);
});
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close(false);
});
}
/* ── Go to path & Bookmarks ───────────────────────────────────── */
_promptGoToPath(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
this._showInputModal({
title: "Go to Path",
placeholder: "/path/to/directory",
initialValue: state.cwd,
confirmLabel: "Navigate",
onConfirm: function (value) {
var path = value.trim();
if (path) this._navigate(sessionId, path);
}.bind(this),
});
}
_showBookmarks(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var self = this;
var bookmarks = this._getBookmarks(sessionId);
var backdrop = createElement("div", "modal-backdrop");
var modal = createElement("div", "modal modal-sm");
var listHtml = "";
if (bookmarks.length === 0) {
listHtml =
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);text-align:center;padding:var(--space-3);">No bookmarks yet. Navigate to a directory and click "Bookmark Current".</div>';
} else {
for (var i = 0; i < bookmarks.length; i++) {
listHtml +=
'<div class="sftp-bookmark-row" data-index="' +
i +
'" data-path="' +
escapeHtml(bookmarks[i]) +
'">';
listHtml +=
'<span style="flex:1;font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
escapeHtml(bookmarks[i]) +
"</span>";
listHtml +=
'<button class="btn btn-xs btn-ghost sftp-bookmark-del" data-index="' +
i +
'" title="Remove">✕</button>';
listHtml += "</div>";
}
}
modal.innerHTML =
'<div class="modal-header">' +
'<span class="modal-title">Directory Bookmarks</span>' +
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
"</div>" +
'<div class="modal-body">' +
'<div class="sftp-bookmark-list">' +
listHtml +
"</div>" +
"</div>" +
'<div class="modal-footer">' +
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Close</button>' +
'<button class="btn btn-sm btn-primary sftp-bookmark-add">★ Bookmark Current</button>' +
"</div>";
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(function () {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
var closed = false;
function close() {
if (closed) return;
closed = true;
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(function () {
backdrop.remove();
}, 200);
}
modal.querySelector(".modal-close").addEventListener("click", close);
modal
.querySelector(".sftp-modal-cancel")
.addEventListener("click", close);
backdrop.addEventListener("click", function (e) {
if (e.target === backdrop) close();
});
// Navigate to bookmark on click
modal.querySelectorAll(".sftp-bookmark-row").forEach(function (row) {
row.addEventListener("click", function (e) {
if (e.target.classList.contains("sftp-bookmark-del")) return;
var path = row.dataset.path;
close();
self._navigate(sessionId, path);
});
row.addEventListener("mousedown", function (e) {
e.preventDefault();
});
});
// Delete bookmark
modal.querySelectorAll(".sftp-bookmark-del").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.stopPropagation();
var idx = parseInt(btn.dataset.index, 10);
self._removeBookmark(sessionId, idx);
close();
self._showBookmarks(sessionId);
});
});
// Add current directory as bookmark
modal
.querySelector(".sftp-bookmark-add")
.addEventListener("click", function () {
self._addBookmark(sessionId, state.cwd);
close();
self._showBookmarks(sessionId);
});
}
_getBookmarks(sessionId) {
try {
var key = "sftp-bookmarks-" + sessionId;
var stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : [];
} catch (_) {
return [];
}
}
_addBookmark(sessionId, path) {
var bookmarks = this._getBookmarks(sessionId);
if (bookmarks.indexOf(path) === -1) {
bookmarks.push(path);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
this.app.notifications.success("Bookmarked: " + path);
} else {
this.app.notifications.info("Already bookmarked");
}
}
_removeBookmark(sessionId, index) {
var bookmarks = this._getBookmarks(sessionId);
bookmarks.splice(index, 1);
localStorage.setItem(
"sftp-bookmarks-" + sessionId,
JSON.stringify(bookmarks),
);
}
/* ── Transfer progress queue ─────────────────────────────────── */
/**
* Handles a transfer progress event from the backend.
* @param {Object} payload - { sessionID, direction, transferred, total, path, done }
*/
handleTransferProgress(payload) {
var state = this.panels.get(payload.sessionID);
if (!state) {
// Panel not open — still track so we can show a notification on completion
if (payload.done) {
var dir =
payload.direction === "upload" ? "↑ Uploaded" : "↓ Downloaded";
var name = payload.path.split("/").pop() || payload.path;
this.app.notifications.success(dir + " " + name);
}
return;
}
// Initialise transfers map if needed
if (!state.transfers) state.transfers = new Map();
var key = payload.direction + ":" + payload.path;
if (payload.done) {
state.transfers.delete(key);
this._renderTransferQueue(payload.sessionID);
// Refresh file list after upload completes
if (payload.direction === "upload") {
this._refresh(payload.sessionID);
}
return;
}
state.transfers.set(key, {
direction: payload.direction,
path: payload.path,
name: payload.path.split("/").pop() || payload.path,
transferred: payload.transferred,
total: payload.total,
});
this._renderTransferQueue(payload.sessionID);
}
/**
* Renders the transfer queue panel showing active transfers.
*/
_renderTransferQueue(sessionId) {
var state = this.panels.get(sessionId);
if (!state) return;
var queue = state.el.querySelector(".sftp-transfer-queue");
if (!state.transfers || state.transfers.size === 0) {
if (queue) queue.remove();
return;
}
if (!queue) {
queue = createElement("div", "sftp-transfer-queue");
// Insert between toolbar and file-list
var fileList = state.el.querySelector(".sftp-file-list");
state.el.insertBefore(queue, fileList);
}
queue.innerHTML = "";
var header = createElement("div", "sftp-transfer-header");
header.textContent = "Transfers (" + state.transfers.size + ")";
queue.appendChild(header);
var self = this;
state.transfers.forEach(function (transfer) {
var row = createElement("div", "sftp-transfer-row");
var icon = createElement("span", "sftp-transfer-icon");
icon.textContent = transfer.direction === "upload" ? "↑" : "↓";
row.appendChild(icon);
var info = createElement("div", "sftp-transfer-info");
var nameEl = createElement("span", "sftp-transfer-name", {
textContent: transfer.name,
title: transfer.path,
});
info.appendChild(nameEl);
var progress = createElement("div", "sftp-transfer-progress");
var bar = createElement("div", "sftp-transfer-bar");
var pct =
transfer.total > 0
? Math.min(
100,
Math.round((transfer.transferred / transfer.total) * 100),
)
: 0;
bar.style.width = pct + "%";
progress.appendChild(bar);
info.appendChild(progress);
var meta = createElement("span", "sftp-transfer-meta");
var transferredStr = formatBytes(transfer.transferred);
var totalStr = transfer.total > 0 ? formatBytes(transfer.total) : "?";
meta.textContent =
transferredStr + " / " + totalStr + " (" + pct + "%)";
info.appendChild(meta);
row.appendChild(info);
queue.appendChild(row);
});
}
/* ── Resize handle ────────────────────────────────────────────── */
_setupResize(sessionId, state) {
var handle = state.handle;
var panel = state.el;
var self = this;
var startX, startWidth;
function onMouseMove(e) {
var delta = startX - e.clientX;
var newWidth = Math.max(
180,
Math.min(startWidth + delta, window.innerWidth * 0.5),
);
panel.style.width = newWidth + "px";
}
function onMouseUp() {
handle.classList.remove("is-dragging");
document.body.style.userSelect = "";
document.body.style.cursor = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
self._refitTerminal(sessionId);
}
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
startX = e.clientX;
startWidth = panel.offsetWidth;
handle.classList.add("is-dragging");
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
/* ── Terminal refit ────────────────────────────────────────────── */
_refitTerminal(sessionId) {
if (
this.app.terminals &&
typeof this.app.terminals.fitSession === "function"
) {
// Small delay lets the layout settle before measuring
setTimeout(
function () {
this.app.terminals.fitSession(sessionId);
}.bind(this),
50,
);
}
}
}
/* ── Export ────────────────────────────────────────────────────────── */
window.NexusTerminal = window.NexusTerminal || {};
window.NexusTerminal.SFTPBrowser = SFTPBrowser;
})();