feat: improve local terminal workflows
This commit is contained in:
@@ -66,6 +66,10 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#mode-leviathan[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-mode-toggle.is-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
+25
-2
@@ -191,13 +191,23 @@
|
||||
);
|
||||
this.updateStatusBar();
|
||||
|
||||
const session = this.state.getSession(payload.sessionID);
|
||||
const isLocal = session?.protocol === "local";
|
||||
const localExited =
|
||||
isLocal && payload.state === "disconnected";
|
||||
const showDisconnectedOverlay =
|
||||
payload.state === "disconnected" || payload.state === "error";
|
||||
!localExited &&
|
||||
(payload.state === "disconnected" || payload.state === "error");
|
||||
this.terminals.setDisconnectedOverlay(
|
||||
payload.sessionID,
|
||||
showDisconnectedOverlay,
|
||||
payload.error,
|
||||
);
|
||||
if (localExited) {
|
||||
this.terminals.setLocalExited(payload.sessionID, true);
|
||||
} else if (isLocal && payload.state === "connected") {
|
||||
this.terminals.setLocalExited(payload.sessionID, false);
|
||||
}
|
||||
|
||||
if (payload.state === "error" && payload.error) {
|
||||
this._handleSessionError(payload.sessionID, payload.error);
|
||||
@@ -612,7 +622,10 @@
|
||||
* @param {"nexus"|"leviathan"} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this.mode = mode === "leviathan" ? "leviathan" : "nexus";
|
||||
const leviathanEnabled =
|
||||
this.state.preferences?.leviathanEnabled === true;
|
||||
this.mode =
|
||||
mode === "leviathan" && leviathanEnabled ? "leviathan" : "nexus";
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
root.classList.toggle("mode-leviathan", this.mode === "leviathan");
|
||||
@@ -1374,6 +1387,16 @@
|
||||
prefs.showStatusBar === false ? "none" : "flex";
|
||||
}
|
||||
|
||||
// Leviathan is an opt-in mode, revealed from the About window.
|
||||
const leviathanEnabled = prefs.leviathanEnabled === true;
|
||||
const leviathanButton = document.getElementById("mode-leviathan");
|
||||
if (leviathanButton) {
|
||||
leviathanButton.hidden = !leviathanEnabled;
|
||||
}
|
||||
if (!leviathanEnabled && this.mode === "leviathan") {
|
||||
this.setMode("nexus");
|
||||
}
|
||||
|
||||
// Refit any visible terminal after geometry changes.
|
||||
requestAnimationFrame(() => {
|
||||
this.terminals.fitActive();
|
||||
|
||||
@@ -18,6 +18,19 @@
|
||||
{ key: "about", label: "About" },
|
||||
];
|
||||
|
||||
const KONAMI_SEQUENCE = [
|
||||
"ArrowUp",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"b",
|
||||
"a",
|
||||
];
|
||||
|
||||
class Settings {
|
||||
/**
|
||||
* @param {App} app
|
||||
@@ -36,6 +49,10 @@
|
||||
|
||||
/** @type {Array<Object>} Cached known hosts list. */
|
||||
this._knownHosts = [];
|
||||
|
||||
this._aboutKonamiHandler = null;
|
||||
this._konamiIndex = 0;
|
||||
this._konamiTogglePending = false;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -75,6 +92,7 @@
|
||||
* Closes the settings modal.
|
||||
*/
|
||||
close() {
|
||||
this._detachAboutKonamiListener();
|
||||
if (this._overlay) {
|
||||
this._overlay.remove();
|
||||
this._overlay = null;
|
||||
@@ -198,6 +216,7 @@
|
||||
* @private
|
||||
*/
|
||||
_renderTabContent(body) {
|
||||
this._detachAboutKonamiListener();
|
||||
body.innerHTML = "";
|
||||
body.classList.remove("help-layout");
|
||||
switch (this._activeTab) {
|
||||
@@ -1058,10 +1077,74 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._attachAboutKonamiListener();
|
||||
}
|
||||
|
||||
// ── Form helpers ─────────────────────────────────────────────────
|
||||
|
||||
_attachAboutKonamiListener() {
|
||||
this._detachAboutKonamiListener();
|
||||
this._konamiIndex = 0;
|
||||
this._aboutKonamiHandler = (event) => {
|
||||
if (!this._overlay || this._activeTab !== "about") return;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
this._konamiIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (key === KONAMI_SEQUENCE[this._konamiIndex]) {
|
||||
event.preventDefault();
|
||||
this._konamiIndex += 1;
|
||||
if (this._konamiIndex === KONAMI_SEQUENCE.length) {
|
||||
this._konamiIndex = 0;
|
||||
void this._toggleLeviathanFromKonami();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._konamiIndex = key === KONAMI_SEQUENCE[0] ? 1 : 0;
|
||||
};
|
||||
window.addEventListener("keydown", this._aboutKonamiHandler);
|
||||
}
|
||||
|
||||
_detachAboutKonamiListener() {
|
||||
if (this._aboutKonamiHandler) {
|
||||
window.removeEventListener("keydown", this._aboutKonamiHandler);
|
||||
this._aboutKonamiHandler = null;
|
||||
}
|
||||
this._konamiIndex = 0;
|
||||
}
|
||||
|
||||
async _toggleLeviathanFromKonami() {
|
||||
if (this._konamiTogglePending) return;
|
||||
this._konamiTogglePending = true;
|
||||
|
||||
const enabled =
|
||||
this.app.state.preferences?.leviathanEnabled !== true;
|
||||
const prefs = {
|
||||
...this.app.state.preferences,
|
||||
leviathanEnabled: enabled,
|
||||
};
|
||||
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.SavePreferences(prefs);
|
||||
this._prefs.leviathanEnabled = enabled;
|
||||
this.app.state.setPreferences(prefs);
|
||||
this.app._applyPreferences?.();
|
||||
this.app.notifications.success(
|
||||
enabled ? "Leviathan enabled" : "Leviathan disabled",
|
||||
);
|
||||
} catch (err) {
|
||||
this.app.notifications.error(
|
||||
"Failed to update Leviathan setting: " + err,
|
||||
);
|
||||
} finally {
|
||||
this._konamiTogglePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single settings form field.
|
||||
* @param {string} type
|
||||
|
||||
@@ -385,35 +385,51 @@ class SplitPaneManager {
|
||||
|
||||
// Build profile list
|
||||
var profiles = this.app.state.profiles || [];
|
||||
if (profiles.length > 0) {
|
||||
var list = document.createElement('div');
|
||||
list.className = 'split-pane-profile-list';
|
||||
var list = document.createElement('div');
|
||||
list.className = 'split-pane-profile-list';
|
||||
|
||||
var self = this;
|
||||
profiles.forEach(function(profile) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'split-pane-profile-btn';
|
||||
var self = this;
|
||||
var localBtn = document.createElement('button');
|
||||
localBtn.className = 'split-pane-profile-btn';
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'protocol-badge';
|
||||
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
|
||||
badge.style.color = self._protocolColor(profile.protocol);
|
||||
var localBadge = document.createElement('span');
|
||||
localBadge.className = 'protocol-badge';
|
||||
localBadge.textContent = 'LOCAL';
|
||||
localBadge.style.color = self._protocolColor('local');
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = profile.displayName || profile.host || profile.id;
|
||||
var localLabel = document.createElement('span');
|
||||
localLabel.textContent = 'Local Machine';
|
||||
|
||||
btn.appendChild(badge);
|
||||
btn.appendChild(label);
|
||||
localBtn.appendChild(localBadge);
|
||||
localBtn.appendChild(localLabel);
|
||||
localBtn.addEventListener('click', function() {
|
||||
self._connectLocalInPane(paneEl, ownerSessionId);
|
||||
});
|
||||
list.appendChild(localBtn);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
self._connectInPane(paneEl, profile, ownerSessionId);
|
||||
});
|
||||
profiles.forEach(function(profile) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'split-pane-profile-btn';
|
||||
|
||||
list.appendChild(btn);
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'protocol-badge';
|
||||
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
|
||||
badge.style.color = self._protocolColor(profile.protocol);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.textContent = profile.displayName || profile.host || profile.id;
|
||||
|
||||
btn.appendChild(badge);
|
||||
btn.appendChild(label);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
self._connectInPane(paneEl, profile, ownerSessionId);
|
||||
});
|
||||
|
||||
placeholder.appendChild(list);
|
||||
}
|
||||
list.appendChild(btn);
|
||||
});
|
||||
|
||||
placeholder.appendChild(list);
|
||||
|
||||
paneEl.appendChild(placeholder);
|
||||
}
|
||||
@@ -427,7 +443,6 @@ class SplitPaneManager {
|
||||
*/
|
||||
_connectInPane(paneEl, profile, ownerSessionId) {
|
||||
var self = this;
|
||||
var node = paneEl._splitNode;
|
||||
|
||||
// Remove placeholder
|
||||
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
||||
@@ -435,25 +450,7 @@ class SplitPaneManager {
|
||||
|
||||
// Connect via backend
|
||||
window.NexusTerminal.wailsService.ConnectSession(profile.id).then(function(sessionInfo) {
|
||||
self.app.state.addSession(sessionInfo);
|
||||
|
||||
// Create a terminal container inside this pane
|
||||
var termContainer = document.createElement('div');
|
||||
termContainer.className = 'terminal-container';
|
||||
termContainer.id = 'split-term-' + sessionInfo.id;
|
||||
termContainer.style.position = 'relative';
|
||||
termContainer.style.flex = '1';
|
||||
termContainer.style.width = '100%';
|
||||
termContainer.style.height = '100%';
|
||||
paneEl.appendChild(termContainer);
|
||||
|
||||
// Track which session is in this pane
|
||||
if (node) {
|
||||
node.sessionId = sessionInfo.id;
|
||||
}
|
||||
|
||||
// Create terminal
|
||||
self.app.terminals.createTerminal(sessionInfo.id, termContainer.id, profile);
|
||||
self._attachTerminalToPane(paneEl, sessionInfo, profile);
|
||||
|
||||
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
|
||||
}).catch(function(err) {
|
||||
@@ -463,6 +460,59 @@ class SplitPaneManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a local shell in a specific pane.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {string} ownerSessionId
|
||||
* @private
|
||||
*/
|
||||
_connectLocalInPane(paneEl, ownerSessionId) {
|
||||
var self = this;
|
||||
var placeholder = paneEl.querySelector('.split-pane-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
window.NexusTerminal.wailsService.ConnectLocal().then(function(sessionInfo) {
|
||||
var prefs = self.app.state.preferences || {};
|
||||
self._attachTerminalToPane(paneEl, sessionInfo, {
|
||||
terminalFontFamily: prefs.defaultFontFamily,
|
||||
terminalFontSize: prefs.defaultFontSize,
|
||||
terminalScrollback: prefs.defaultScrollback,
|
||||
terminalCursorStyle: prefs.defaultCursorStyle
|
||||
});
|
||||
self.app.notifications.info('Opening local terminal…');
|
||||
}).catch(function(err) {
|
||||
self.app.notifications.error('Local terminal failed: ' + err);
|
||||
self._renderPlaceholder(paneEl, ownerSessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a connected terminal session to a split pane.
|
||||
* @param {HTMLElement} paneEl
|
||||
* @param {Object} sessionInfo
|
||||
* @param {Object} terminalProfile
|
||||
* @private
|
||||
*/
|
||||
_attachTerminalToPane(paneEl, sessionInfo, terminalProfile) {
|
||||
this.app.state.addSession(sessionInfo);
|
||||
|
||||
var termContainer = document.createElement('div');
|
||||
termContainer.className = 'terminal-container';
|
||||
termContainer.id = 'split-term-' + sessionInfo.id;
|
||||
termContainer.style.position = 'relative';
|
||||
termContainer.style.flex = '1';
|
||||
termContainer.style.width = '100%';
|
||||
termContainer.style.height = '100%';
|
||||
paneEl.appendChild(termContainer);
|
||||
|
||||
var node = paneEl._splitNode;
|
||||
if (node) {
|
||||
node.sessionId = sessionInfo.id;
|
||||
}
|
||||
|
||||
this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile);
|
||||
}
|
||||
|
||||
// ── Tree traversal helpers ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -588,7 +638,8 @@ class SplitPaneManager {
|
||||
ssh: '#4ecdc4',
|
||||
telnet: '#ffa726',
|
||||
raw: '#ab47bc',
|
||||
github: '#7c8aff'
|
||||
github: '#7c8aff',
|
||||
local: '#66bb6a'
|
||||
};
|
||||
return colors[protocol] || '#4ecdc4';
|
||||
}
|
||||
|
||||
@@ -224,6 +224,10 @@
|
||||
|
||||
try {
|
||||
fitAddon.fit();
|
||||
// Forced fits do not emit onResize when xterm's geometry is
|
||||
// unchanged. Always publish the fitted size so a new local PTY
|
||||
// cannot remain at its 80x24 fallback dimensions.
|
||||
this._sendTerminalSize(sessionId, term);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
@@ -249,6 +253,14 @@
|
||||
|
||||
// Forward user keystrokes to the backend
|
||||
term.onData((data) => {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (entry?.localExited) {
|
||||
if (data === "\r" || data === "\n") {
|
||||
this._restartLocalTerminal(sessionId, entry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const b64 = utf8ToBase64(data);
|
||||
window.NexusTerminal.wailsService.WriteSession(sessionId, b64);
|
||||
@@ -330,11 +342,7 @@
|
||||
|
||||
// Notify backend when terminal dimensions change
|
||||
term.onResize(({ cols, rows }) => {
|
||||
try {
|
||||
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows);
|
||||
} catch (err) {
|
||||
console.error("ResizeSession error:", err);
|
||||
}
|
||||
this._sendTerminalSize(sessionId, { cols, rows });
|
||||
});
|
||||
|
||||
// Re-fit when the container itself resizes. ResizeObserver can fire
|
||||
@@ -354,6 +362,8 @@
|
||||
scheduleFit,
|
||||
cancelScheduledFit,
|
||||
refreshTerminal,
|
||||
localExited: false,
|
||||
localRestarting: false,
|
||||
decoder:
|
||||
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
|
||||
});
|
||||
@@ -461,6 +471,24 @@
|
||||
overlay.classList.toggle("is-visible", isDisconnected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a local shell as exited without obscuring its terminal buffer.
|
||||
* Enter is intercepted by the terminal input handler and restarts it.
|
||||
* @param {string} sessionId
|
||||
* @param {boolean} isExited
|
||||
*/
|
||||
setLocalExited(sessionId, isExited) {
|
||||
const entry = this.terminals.get(sessionId);
|
||||
if (!entry) return;
|
||||
|
||||
if (isExited && !entry.localExited) {
|
||||
entry.term.write("\r\n[Exited - press enter to restart]\r\n");
|
||||
}
|
||||
entry.localExited = isExited;
|
||||
entry.localRestarting = false;
|
||||
if (isExited) entry.term.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches within a terminal using the search addon.
|
||||
* @param {string} sessionId
|
||||
@@ -550,6 +578,34 @@
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────
|
||||
|
||||
_sendTerminalSize(sessionId, dimensions) {
|
||||
const cols = Number(dimensions?.cols || 0);
|
||||
const rows = Number(dimensions?.rows || 0);
|
||||
if (cols <= 0 || rows <= 0) return;
|
||||
|
||||
Promise.resolve(
|
||||
window.NexusTerminal.wailsService.ResizeSession(sessionId, cols, rows),
|
||||
).catch((err) => console.error("ResizeSession error:", err));
|
||||
}
|
||||
|
||||
async _restartLocalTerminal(sessionId, entry) {
|
||||
if (entry.localRestarting) return;
|
||||
entry.localRestarting = true;
|
||||
|
||||
try {
|
||||
await window.NexusTerminal.wailsService.RestartLocalSession(
|
||||
sessionId,
|
||||
entry.term.cols,
|
||||
entry.term.rows,
|
||||
);
|
||||
} catch (err) {
|
||||
entry.localRestarting = false;
|
||||
entry.term.write(
|
||||
`\r\n[Restart failed: ${String(err)} - press enter to retry]\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the disconnect overlay exists for a session panel.
|
||||
* @param {HTMLElement} panel
|
||||
|
||||
Reference in New Issue
Block a user