feat: implement local terminal support with shell configuration and UI integration

This commit is contained in:
Andy Dixon
2026-05-21 13:13:06 +01:00
parent 3ff04feb92
commit a7cf28b404
21 changed files with 626 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
ce0d75b28075a7688ba4535c94b4784c
99c6bbb0236daee684251a5f62bbd974
+10
View File
@@ -467,6 +467,16 @@
color: var(--danger);
}
.settings-shell-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.settings-shell-row .form-input {
flex: 1;
}
.form-success {
font-size: var(--text-xs);
color: var(--success);
+21
View File
@@ -253,6 +253,12 @@
.sidebar-item .protocol-indicator.proto-github::before {
content: "GH";
}
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18);
@@ -270,6 +276,21 @@
background: rgba(124, 138, 255, 0.18);
color: var(--protocol-github);
}
.sidebar-item .protocol-indicator.proto-vnc {
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
}
/* Local machine item separator */
.sidebar-item-local {
border-bottom: 1px solid var(--border-subtle);
margin: var(--space-2) var(--space-2) var(--space-2);
padding-bottom: var(--space-2);
}
/* Text block (name + host) */
.sidebar-item-text {
+8
View File
@@ -114,6 +114,14 @@
background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github);
}
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* -----------------------------------------------------------------------
Status dot (connection state)
+1
View File
@@ -58,6 +58,7 @@
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
Spacing scale (4 px base unit)
+27
View File
@@ -541,6 +541,9 @@
this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
this._bindClick("btn-broadcast", () => this.toggleBroadcast());
// Wire the "Local Machine" sidebar button
this.sidebar.onConnectLocal = () => this.connectLocal();
// Right-click on broadcast button opens target selector
const broadcastBtn = document.getElementById("btn-broadcast");
if (broadcastBtn) {
@@ -809,6 +812,30 @@
}
}
/**
* Opens a local terminal session.
*/
async connectLocal() {
try {
const sessionInfo = await window.go.api.Service.ConnectLocal();
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
const prefs = this.state.preferences || {};
this.terminals.createTerminal(sessionInfo.id, panelId, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle,
});
this.notifications.info("Opening local terminal…");
} catch (err) {
this.notifications.error("Local terminal failed: " + err);
}
}
/**
* Opens a quick connect dialog for ad-hoc connections without saving a profile.
*/
+8
View File
@@ -107,6 +107,14 @@
action: () => this.app.quickConnect(),
});
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Tab actions
cmds.push({
id: "close-tab",
+46
View File
@@ -53,6 +53,12 @@
this._knownHosts = [];
}
try {
this._defaultShell = await window.go.api.Service.GetDefaultShell();
} catch (_) {
this._defaultShell = "";
}
this._render();
}
@@ -281,6 +287,45 @@
{ min: 180, max: 500 },
),
);
// Local shell path with browse button
const shellGroup = createElement("div", "form-group settings-field");
const shellLabel = createElement("label", "form-label", {
textContent: "Local shell",
for: "setting-localShell",
});
shellGroup.appendChild(shellLabel);
const shellRow = createElement("div", "settings-shell-row");
const shellInput = createElement("input", "form-input", {
type: "text",
id: "setting-localShell",
name: "setting-localShell",
value: p.localShell || "",
placeholder: this._defaultShell || "/bin/zsh",
});
shellRow.appendChild(shellInput);
const browseBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Browse…",
type: "button",
});
browseBtn.addEventListener("click", async () => {
try {
const path = await window.go.api.Service.BrowseForShell();
if (path) {
shellInput.value = path;
}
} catch (_) {}
});
shellRow.appendChild(browseBtn);
shellGroup.appendChild(shellRow);
const shellHint = createElement("span", "form-hint", {
textContent: "Leave empty to use system default",
});
shellGroup.appendChild(shellHint);
body.appendChild(shellGroup);
}
/**
@@ -1050,6 +1095,7 @@
"defaultScrollback",
"defaultCursorStyle",
"useNativePaste",
"localShell",
];
for (const field of fields) {
+33
View File
@@ -68,6 +68,9 @@
this.el.appendChild(this._renderBulkActions());
}
// Local Machine quick-connect button
this.el.appendChild(this._renderLocalMachineItem());
// Favourites
const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) {
@@ -531,6 +534,36 @@
);
}
/** @private */
_renderLocalMachineItem() {
const item = createElement("div", "sidebar-item sidebar-item-local");
item.addEventListener("mousedown", (e) => e.preventDefault());
const indicator = createElement(
"span",
"protocol-indicator proto-local",
{
title: "Local",
},
);
item.appendChild(indicator);
const textWrapper = createElement("div", "sidebar-item-text");
const nameEl = createElement("span", "sidebar-item-name", {
textContent: "Local Machine",
});
textWrapper.appendChild(nameEl);
item.appendChild(textWrapper);
item.addEventListener("click", () => {
if (this.onConnectLocal) {
this.onConnectLocal();
}
});
return item;
}
/** @private */
_renderBulkActions() {
const bar = createElement("div", "sidebar-bulk-actions");
+3 -1
View File
@@ -480,7 +480,9 @@
const protoLabel =
sessionInfo.protocol === "github"
? "GitHub"
: sessionInfo.protocol.toUpperCase();
: sessionInfo.protocol === "local"
? "Local"
: sessionInfo.protocol.toUpperCase();
const badge = createElement(
"span",
`tab-badge badge-${sessionInfo.protocol}`,
+2
View File
@@ -229,6 +229,8 @@
return "proto-github";
case "vnc":
return "proto-vnc";
case "local":
return "proto-local";
default:
return "";
}
+17
View File
@@ -1,8 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
import {api} from '../models';
import {context} from '../models';
export function BrowseForShell():Promise<string>;
export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>;
@@ -11,6 +14,8 @@ export function ClearSessionHistory(arg1:string):Promise<void>;
export function CloseAllSessions():Promise<void>;
export function ConnectLocal():Promise<models.SessionInfo>;
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
@@ -27,6 +32,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
export function DeleteRecording(arg1:string):Promise<void>;
export function DeleteSSHKey(arg1:string):Promise<void>;
export function DeleteSnippet(arg1:string):Promise<void>;
export function DeleteTOTPSecret(arg1:string):Promise<void>;
@@ -45,20 +52,28 @@ export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function GetAppInfo():Promise<Record<string, string>>;
export function GetDefaultShell():Promise<string>;
export function GetPreferences():Promise<models.AppPreferences>;
export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetSSHPublicKey(arg1:string):Promise<string>;
export function GetSessionCwd(arg1:string):Promise<string>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>;
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
@@ -93,6 +108,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>;
+32
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function BrowseForShell() {
return window['go']['api']['Service']['BrowseForShell']();
}
export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1);
}
@@ -18,6 +22,10 @@ export function CloseAllSessions() {
return window['go']['api']['Service']['CloseAllSessions']();
}
export function ConnectLocal() {
return window['go']['api']['Service']['ConnectLocal']();
}
export function ConnectQuick(arg1) {
return window['go']['api']['Service']['ConnectQuick'](arg1);
}
@@ -50,6 +58,10 @@ export function DeleteRecording(arg1) {
return window['go']['api']['Service']['DeleteRecording'](arg1);
}
export function DeleteSSHKey(arg1) {
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
}
export function DeleteSnippet(arg1) {
return window['go']['api']['Service']['DeleteSnippet'](arg1);
}
@@ -86,10 +98,18 @@ export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
}
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
}
export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo']();
}
export function GetDefaultShell() {
return window['go']['api']['Service']['GetDefaultShell']();
}
export function GetPreferences() {
return window['go']['api']['Service']['GetPreferences']();
}
@@ -102,6 +122,10 @@ export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1);
}
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1);
}
@@ -114,6 +138,10 @@ export function GetTOTPCode(arg1) {
return window['go']['api']['Service']['GetTOTPCode'](arg1);
}
export function GetVNCWebSocketURL(arg1) {
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
}
export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1);
}
@@ -182,6 +210,10 @@ export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings']();
}
export function ListSSHKeyPairs() {
return window['go']['api']['Service']['ListSSHKeyPairs']();
}
export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys']();
}
+70
View File
@@ -1,3 +1,28 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
this.privateKeyPath = source["privateKeyPath"];
}
}
}
export namespace models {
export class AppPreferences {
@@ -13,6 +38,7 @@ export namespace models {
defaultScrollback: number;
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) {
return new AppPreferences(source);
@@ -32,6 +58,7 @@ export namespace models {
this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
}
}
export class AuditEntry {
@@ -222,6 +249,46 @@ export namespace models {
this.label = source["label"];
}
}
export class VNCConfig {
vncHost: string;
vncPort: number;
secretMode: string;
viewOnly: boolean;
scaleToFit: boolean;
quality: number;
sharedSession: boolean;
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
static createFrom(source: any = {}) {
return new VNCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.vncHost = source["vncHost"];
this.vncPort = source["vncPort"];
this.secretMode = source["secretMode"];
this.viewOnly = source["viewOnly"];
this.scaleToFit = source["scaleToFit"];
this.quality = source["quality"];
this.sharedSession = source["sharedSession"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
}
}
export class RAWConfig {
autoReconnect: boolean;
lineEnding: string;
@@ -410,6 +477,7 @@ export namespace models {
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
@@ -442,6 +510,7 @@ export namespace models {
this.telnet = this.convertValues(source["telnet"], TelnetConfig);
this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -522,6 +591,7 @@ export namespace models {
this.updatedAt = source["updatedAt"];
}
}
}