Compare commits
9
Commits
v1.0.0-RELEASE
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b6f611110 | ||
|
|
a7cf28b404 | ||
|
|
3ff04feb92 | ||
|
|
96fedb9348 | ||
|
|
523d9b02b4 | ||
|
|
421bdb9ee8 | ||
|
|
c9b5eb10eb | ||
|
|
96c603f3f4 | ||
|
|
23aaa1b53e |
@@ -29,6 +29,7 @@ If a session drops, use the **Reconnect** button in the disconnected overlay.
|
||||
- Product overview: [docs/overview.md](docs/overview.md)
|
||||
- Quick start (shareable): [docs/quick-start.md](docs/quick-start.md)
|
||||
- User manual: [docs/user-manual.md](docs/user-manual.md)
|
||||
- Build and release: [docs/build-and-release.md](docs/build-and-release.md)
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
@@ -100,6 +108,8 @@ build_target() {
|
||||
local version="$4"
|
||||
local ldflags
|
||||
ldflags="$(build_ldflags "${version}")"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
|
||||
local platform="${target_os}/${target_arch}"
|
||||
local cc=""
|
||||
@@ -114,10 +124,18 @@ build_target() {
|
||||
|
||||
log "Building ${platform}…"
|
||||
if [ -n "$cc" ]; then
|
||||
if [ -n "$tags" ]; then
|
||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
|
||||
else
|
||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
fi
|
||||
else
|
||||
if [ -n "$tags" ]; then
|
||||
CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags"
|
||||
else
|
||||
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
@@ -17,6 +17,14 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
build_version() {
|
||||
local tag
|
||||
local commit
|
||||
@@ -81,7 +89,14 @@ build() {
|
||||
log "Building production binary…"
|
||||
local version
|
||||
version="$(build_version)"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
if [ -n "$tags" ]; then
|
||||
log "Using Wails build tags: ${tags}"
|
||||
wails build -tags "$tags" -ldflags "$(build_ldflags "${version}")"
|
||||
else
|
||||
wails build -ldflags "$(build_ldflags "${version}")"
|
||||
fi
|
||||
log "Build complete: build/bin/"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Build and Release
|
||||
|
||||
This project uses [Wails](https://wails.io/) for desktop packaging.
|
||||
|
||||
## Ubuntu 24.04 prerequisites
|
||||
|
||||
Install the Linux build toolchain and Wails runtime headers with:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
pkg-config \
|
||||
zip
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Ubuntu 24.04 does not ship `libwebkit2gtk-4.0-dev`.
|
||||
- Wails `v2.12.0` supports Ubuntu 24.04 by building with the `webkit2_41` tag.
|
||||
- The local build scripts detect that package layout automatically and add the tag when needed.
|
||||
|
||||
You will also need these tools available in `PATH`:
|
||||
|
||||
- `go`
|
||||
- `node`
|
||||
- `npm`
|
||||
- `wails`
|
||||
|
||||
Install the Wails CLI with:
|
||||
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||
```
|
||||
|
||||
## Build commands
|
||||
|
||||
Build the app binary for the current host:
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
Build a packaged release for the current host platform:
|
||||
|
||||
```bash
|
||||
./release.sh
|
||||
```
|
||||
|
||||
By default, `./release.sh` now builds only the current host OS/architecture. On this Ubuntu 24.04 `amd64` host that means `linux/amd64`.
|
||||
|
||||
## Optional cross-compilers
|
||||
|
||||
If you want to add extra Linux/Windows targets from Ubuntu, install the relevant cross-compiler packages first:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-mingw-w64-x86-64
|
||||
```
|
||||
|
||||
Then request explicit targets:
|
||||
|
||||
```bash
|
||||
NEXUS_RELEASE_TARGETS=linux/amd64,windows/amd64 ./release.sh
|
||||
```
|
||||
|
||||
## Platform caveats
|
||||
|
||||
- macOS targets cannot be cross-built from Linux with Wails. Build those on macOS.
|
||||
- `windows/arm64` is not covered by the default Ubuntu toolchain here.
|
||||
- `linux/arm64` needs more than `gcc-aarch64-linux-gnu`; it also needs an `arm64` sysroot and target GTK/WebKit development libraries for CGO to link cleanly.
|
||||
- Tested on Ubuntu 24.04 `amd64`: `linux/amd64` and `windows/amd64` build successfully; `linux/arm64` does not in the default host setup above.
|
||||
@@ -22,6 +22,7 @@
|
||||
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||
<link rel="stylesheet" href="/src/css/github.css" />
|
||||
<link rel="stylesheet" href="/src/css/vnc.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app">
|
||||
@@ -76,6 +77,13 @@
|
||||
>
|
||||
<span>+ New</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
id="btn-command-palette"
|
||||
title="Command Palette (⌘K)"
|
||||
>
|
||||
<span>⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-icon btn-sm"
|
||||
id="btn-import"
|
||||
@@ -267,6 +275,7 @@
|
||||
Disconnected
|
||||
</span>
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-latency"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button
|
||||
@@ -304,6 +313,8 @@
|
||||
<script src="/src/js/profiles.js"></script>
|
||||
<script src="/src/js/raw.js"></script>
|
||||
<script src="/src/js/github.js"></script>
|
||||
<script src="/src/js/lib/novnc.min.js"></script>
|
||||
<script src="/src/js/vnc.js"></script>
|
||||
<script src="/src/js/settings.js"></script>
|
||||
<script src="/src/js/commands.js"></script>
|
||||
<script src="/src/js/secrets.js"></script>
|
||||
@@ -311,6 +322,8 @@
|
||||
<script src="/src/js/sftp.js"></script>
|
||||
<script src="/src/js/splitpane.js"></script>
|
||||
<script src="/src/js/snippets.js"></script>
|
||||
<script src="/src/js/sshkeys.js"></script>
|
||||
<script src="/src/js/triggers.js"></script>
|
||||
<script src="/src/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+476
@@ -9,11 +9,487 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
"description": "Frontend for Nexus Terminal desktop SSH client",
|
||||
"scripts": {
|
||||
"install:assets": "node scripts/copy-assets.js",
|
||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||
"build": "node scripts/build.js",
|
||||
"dev": "node scripts/build.js --watch",
|
||||
"postinstall": "npm run install:assets"
|
||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.5.0",
|
||||
"xterm": "5.3.0",
|
||||
"xterm-addon-fit": "0.8.0",
|
||||
"xterm-addon-search": "0.13.0",
|
||||
"xterm-addon-unicode11": "0.6.0",
|
||||
"xterm-addon-web-links": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
ce0d75b28075a7688ba4535c94b4784c
|
||||
99c6bbb0236daee684251a5f62bbd974
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* bundle-novnc.js — Uses esbuild to bundle noVNC's RFB class into a single
|
||||
* script that exposes window.noVNC.RFB for use in the frontend.
|
||||
*
|
||||
* noVNC 1.5+ uses top-level await which is incompatible with IIFE format,
|
||||
* so we bundle as ESM then wrap in an async IIFE via banner/footer.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const esbuild = require("esbuild");
|
||||
const path = require("path");
|
||||
|
||||
const entryPoint = path.resolve(__dirname, "novnc-entry.js");
|
||||
const outFile = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"js",
|
||||
"lib",
|
||||
"novnc.min.js",
|
||||
);
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
outfile: outFile,
|
||||
target: ["safari15"],
|
||||
logLevel: "info",
|
||||
banner: { js: ";(async function() {" },
|
||||
footer: { js: "})();" },
|
||||
})
|
||||
.then(() => {
|
||||
console.log(
|
||||
"[bundle-novnc] noVNC bundled successfully → src/js/lib/novnc.min.js",
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[bundle-novnc] Failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Entry point for esbuild bundling of noVNC.
|
||||
// Assigns RFB to window.noVNC so it's available as a global in the frontend.
|
||||
import RFB from "@novnc/novnc";
|
||||
window.noVNC = { RFB: RFB };
|
||||
+360
-44
@@ -103,7 +103,7 @@
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
}
|
||||
.btn-danger:active {
|
||||
background: rgba(239, 83, 80, 0.20);
|
||||
background: rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
.btn-danger-filled {
|
||||
@@ -172,7 +172,6 @@
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
FORM CONTROLS
|
||||
======================================================================= */
|
||||
@@ -191,7 +190,7 @@
|
||||
}
|
||||
|
||||
.form-label-required::after {
|
||||
content: ' *';
|
||||
content: " *";
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@@ -279,7 +278,7 @@
|
||||
.form-input.is-error:focus,
|
||||
.form-select.is-error:focus,
|
||||
.form-textarea.is-error:focus {
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.20);
|
||||
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
|
||||
}
|
||||
|
||||
/* Inline layout (label beside control) */
|
||||
@@ -325,7 +324,7 @@
|
||||
}
|
||||
|
||||
.form-checkbox input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 5px;
|
||||
@@ -365,7 +364,7 @@
|
||||
}
|
||||
|
||||
.form-toggle input[type="checkbox"]::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
@@ -373,7 +372,9 @@
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
transition: transform var(--transition-normal), background var(--transition-normal);
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
background var(--transition-normal);
|
||||
}
|
||||
|
||||
.form-toggle input[type="checkbox"]:checked {
|
||||
@@ -385,7 +386,6 @@
|
||||
background: var(--text-inverse);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
BADGES
|
||||
======================================================================= */
|
||||
@@ -429,23 +429,36 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.badge-status::before {
|
||||
content: '';
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-status.is-connected::before { background: var(--success); }
|
||||
.badge-status.is-connecting::before { background: var(--warning); animation: pulse-dot 1.2s ease infinite; }
|
||||
.badge-status.is-error::before { background: var(--error); }
|
||||
.badge-status.is-disconnected::before { background: var(--text-tertiary); }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
.badge-status.is-connected::before {
|
||||
background: var(--success);
|
||||
}
|
||||
.badge-status.is-connecting::before {
|
||||
background: var(--warning);
|
||||
animation: pulse-dot 1.2s ease infinite;
|
||||
}
|
||||
.badge-status.is-error::before {
|
||||
background: var(--error);
|
||||
}
|
||||
.badge-status.is-disconnected::before {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
CARDS & SURFACES
|
||||
@@ -478,7 +491,6 @@
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
DROPDOWN / CONTEXT MENU
|
||||
======================================================================= */
|
||||
@@ -530,7 +542,9 @@
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
@@ -545,7 +559,7 @@
|
||||
color: var(--error);
|
||||
}
|
||||
.dropdown-item.is-danger:hover {
|
||||
background: rgba(239, 83, 80, 0.10);
|
||||
background: rgba(239, 83, 80, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item.is-disabled {
|
||||
@@ -581,7 +595,6 @@
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
NOTIFICATIONS (toast)
|
||||
======================================================================= */
|
||||
@@ -617,12 +630,24 @@
|
||||
}
|
||||
|
||||
@keyframes notification-in {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes notification-out {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(20px); }
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
@@ -640,10 +665,18 @@
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.notification-success .notification-icon { background: var(--success); }
|
||||
.notification-error .notification-icon { background: var(--error); }
|
||||
.notification-warning .notification-icon { background: var(--warning); }
|
||||
.notification-info .notification-icon { background: var(--info); }
|
||||
.notification-success .notification-icon {
|
||||
background: var(--success);
|
||||
}
|
||||
.notification-error .notification-icon {
|
||||
background: var(--error);
|
||||
}
|
||||
.notification-warning .notification-icon {
|
||||
background: var(--warning);
|
||||
}
|
||||
.notification-info .notification-icon {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
flex: 1;
|
||||
@@ -673,7 +706,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
.notification-close:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -681,11 +716,18 @@
|
||||
}
|
||||
|
||||
/* Colour variants (left border accent) */
|
||||
.notification-success { border-left: 3px solid var(--success); }
|
||||
.notification-error { border-left: 3px solid var(--error); }
|
||||
.notification-warning { border-left: 3px solid var(--warning); }
|
||||
.notification-info { border-left: 3px solid var(--info); }
|
||||
|
||||
.notification-success {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
.notification-error {
|
||||
border-left: 3px solid var(--error);
|
||||
}
|
||||
.notification-warning {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
.notification-info {
|
||||
border-left: 3px solid var(--info);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
DISCLOSURE / ACCORDION
|
||||
@@ -705,7 +747,9 @@
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
user-select: none;
|
||||
text-align: left;
|
||||
background: none;
|
||||
@@ -743,7 +787,6 @@
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
SEARCH INPUT
|
||||
======================================================================= */
|
||||
@@ -757,7 +800,9 @@
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
@@ -780,7 +825,7 @@
|
||||
}
|
||||
|
||||
.search-wrapper::before {
|
||||
content: '⌕';
|
||||
content: "⌕";
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
top: 50%;
|
||||
@@ -790,7 +835,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
EMPTY STATE
|
||||
======================================================================= */
|
||||
@@ -832,7 +876,6 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
TOOLTIP
|
||||
======================================================================= */
|
||||
@@ -858,7 +901,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
DIVIDER
|
||||
======================================================================= */
|
||||
@@ -876,7 +918,6 @@
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
|
||||
/* =======================================================================
|
||||
LOADING / SPINNER
|
||||
======================================================================= */
|
||||
@@ -897,5 +938,280 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
CONTEXT MENUS
|
||||
======================================================================= */
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal, 1000);
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
padding: var(--space-1) 0;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.35),
|
||||
0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 6px var(--space-3);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.context-menu-item:active {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-disabled {
|
||||
color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.context-menu-item.context-menu-danger:hover {
|
||||
background: rgba(239, 83, 80, 0.1);
|
||||
}
|
||||
|
||||
.context-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: var(--space-1) 0;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
SSH KEY MANAGER
|
||||
======================================================================= */
|
||||
|
||||
.sshkey-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.sshkey-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sshkey-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sshkey-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.sshkey-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sshkey-name {
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sshkey-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sshkey-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sshkey-loading,
|
||||
.sshkey-empty,
|
||||
.sshkey-error {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sshkey-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.sshkey-generate-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.sshkey-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
TRIGGER RULES
|
||||
======================================================================= */
|
||||
|
||||
.trigger-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.trigger-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.trigger-empty {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.trigger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.trigger-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.trigger-toggle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trigger-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trigger-name {
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.trigger-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.trigger-pattern {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent);
|
||||
background: var(--bg-overlay);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trigger-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trigger-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.trigger-form .form-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.trigger-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,6 +169,20 @@
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Latency indicator */
|
||||
#status-latency {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#status-latency.latency-good {
|
||||
color: var(--success);
|
||||
}
|
||||
#status-latency.latency-warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
#status-latency.latency-bad {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Resize handle (between sidebar and workspace)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
@@ -419,3 +419,96 @@
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Transfer progress queue
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-transfer-queue {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sftp-transfer-header {
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
padding: var(--space-1) var(--space-2) 0;
|
||||
}
|
||||
|
||||
.sftp-transfer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 4px var(--space-2);
|
||||
}
|
||||
|
||||
.sftp-transfer-icon {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sftp-transfer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sftp-transfer-name {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sftp-transfer-progress {
|
||||
height: 3px;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sftp-transfer-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease-out;
|
||||
}
|
||||
|
||||
.sftp-transfer-meta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Bookmark list
|
||||
----------------------------------------------------------------------- */
|
||||
.sftp-bookmark-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sftp-bookmark-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.sftp-bookmark-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -163,6 +171,49 @@
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Activity indicator — pulses when background tab has new output
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-activity {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tab-has-activity .tab-activity {
|
||||
opacity: 1;
|
||||
animation: pulse-dot 1.5s ease infinite;
|
||||
}
|
||||
|
||||
.tab-active .tab-activity {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Trigger flash — brief glow when a trigger rule matches */
|
||||
.tab-trigger-flash {
|
||||
animation: trigger-flash 2s ease;
|
||||
}
|
||||
|
||||
@keyframes trigger-flash {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
15% {
|
||||
background: rgba(239, 83, 80, 0.25);
|
||||
}
|
||||
30% {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
45% {
|
||||
background: rgba(239, 83, 80, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
SFTP toggle button — revealed on hover (SSH tabs only)
|
||||
----------------------------------------------------------------------- */
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
--protocol-telnet: #ffa726;
|
||||
--protocol-raw: #ab47bc;
|
||||
--protocol-github: #7c8aff;
|
||||
--protocol-vnc: #e85d4a;
|
||||
--protocol-local: #8bc34a;
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Spacing scale (4 px base unit)
|
||||
@@ -75,10 +77,11 @@
|
||||
/* -----------------------------------------------------------------------
|
||||
Typography
|
||||
----------------------------------------------------------------------- */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco,
|
||||
'Cascadia Code', monospace;
|
||||
--font-sans:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "Fira Code", "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||
|
||||
--text-xs: 11px;
|
||||
--text-sm: 12px;
|
||||
@@ -108,10 +111,10 @@
|
||||
/* -----------------------------------------------------------------------
|
||||
Shadows
|
||||
----------------------------------------------------------------------- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.40);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.50);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.60);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Transitions
|
||||
@@ -145,7 +148,9 @@
|
||||
Global input overrides — disable macOS/WebKit auto-capitalisation,
|
||||
autocorrect, and spellcheck on all text fields.
|
||||
----------------------------------------------------------------------- */
|
||||
input, textarea, select {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
autocapitalize: none;
|
||||
autocorrect: off;
|
||||
spellcheck: false;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* VNC viewer styles */
|
||||
|
||||
.vnc-viewer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.vnc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vnc-status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.vnc-status-connected {
|
||||
color: var(--protocol-ssh);
|
||||
}
|
||||
|
||||
.vnc-status-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-vnc-action {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.btn-vnc-action:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vnc-display {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.vnc-display canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Protocol colour for VNC */
|
||||
.proto-vnc {
|
||||
color: var(--protocol-vnc);
|
||||
}
|
||||
|
||||
.badge-vnc {
|
||||
background: var(--protocol-vnc);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-item-indicator--vnc {
|
||||
background: var(--protocol-vnc);
|
||||
}
|
||||
+305
-10
@@ -20,6 +20,8 @@
|
||||
ExportImportUI,
|
||||
SFTPBrowser,
|
||||
SnippetLibrary,
|
||||
SSHKeyManager,
|
||||
TriggerEngine,
|
||||
stateLabel,
|
||||
} = window.NexusTerminal;
|
||||
|
||||
@@ -40,6 +42,8 @@
|
||||
this.exportImport = new ExportImportUI(this);
|
||||
this.sftp = new SFTPBrowser(this);
|
||||
this.snippets = new SnippetLibrary(this);
|
||||
this.sshKeys = new SSHKeyManager(this);
|
||||
this.triggers = new TriggerEngine(this);
|
||||
this.updateInfo = null;
|
||||
}
|
||||
|
||||
@@ -159,6 +163,14 @@
|
||||
// Incoming session data (terminal output)
|
||||
window.runtime.EventsOn("session:data", (payload) => {
|
||||
this.terminals.handleData(payload.sessionID, payload.data);
|
||||
this.tabs.markActivity(payload.sessionID);
|
||||
// Check trigger rules against decoded text
|
||||
try {
|
||||
const raw = atob(payload.data);
|
||||
this.triggers.check(payload.sessionID, raw);
|
||||
} catch (_) {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
// Session state transitions (connecting → connected → reconnecting → disconnected)
|
||||
@@ -213,15 +225,19 @@
|
||||
}
|
||||
}
|
||||
// Also re-fit to catch any layout changes that occurred while connecting.
|
||||
// Multiple delays help byobu/tmux render correctly after its status
|
||||
// bar initialisation completes.
|
||||
const fit = () => this.terminals.fitSession(payload.sessionID);
|
||||
setTimeout(fit, 50);
|
||||
setTimeout(fit, 300);
|
||||
setTimeout(fit, 800);
|
||||
}
|
||||
});
|
||||
|
||||
// Session closed by backend
|
||||
window.runtime.EventsOn("session:closed", (payload) => {
|
||||
this.sftp.close(payload.sessionID);
|
||||
this.triggers.cleanup(payload.sessionID);
|
||||
this.tabs.handleSessionClosed(payload.sessionID);
|
||||
this.updateStatusBar();
|
||||
});
|
||||
@@ -256,6 +272,14 @@
|
||||
);
|
||||
});
|
||||
|
||||
// VNC WebSocket proxy ready — connect the viewer
|
||||
window.runtime.EventsOn("session:vnc:ready", (payload) => {
|
||||
window.NexusTerminal.VNCViewer.connect(
|
||||
payload.sessionID,
|
||||
payload.wsURL,
|
||||
);
|
||||
});
|
||||
|
||||
// App-wide commands from the native menu
|
||||
window.runtime.EventsOn("app:open-about", () => {
|
||||
this.settings.openTab("about");
|
||||
@@ -319,6 +343,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// SFTP transfer progress events
|
||||
window.runtime.EventsOn("session:sftp:progress", (payload) => {
|
||||
this.sftp.handleTransferProgress(payload);
|
||||
});
|
||||
|
||||
// Password/passphrase prompt requests from the backend
|
||||
window.runtime.EventsOn("session:secret:prompt", async (payload) => {
|
||||
try {
|
||||
@@ -354,6 +383,7 @@
|
||||
this.terminals.destroyTerminal(sessionId);
|
||||
this.rawViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.GitHubViewer.destroyViewer(sessionId);
|
||||
window.NexusTerminal.VNCViewer.destroyViewer(sessionId);
|
||||
this.state.removeSession(sessionId);
|
||||
this.tabs.removeTab(sessionId);
|
||||
this.updateStatusBar();
|
||||
@@ -501,12 +531,27 @@
|
||||
setupUI() {
|
||||
var btnEl = document.getElementById("btn-new-connection");
|
||||
this._bindClick("btn-new-connection", () => this.profileEditor.openNew());
|
||||
this._bindClick("btn-command-palette", () =>
|
||||
this.commandPalette.toggle(),
|
||||
);
|
||||
this._bindClick("btn-settings", () => this.settings.open());
|
||||
this._bindClick("btn-import", () => this.importProfiles());
|
||||
this._bindClick("btn-export", () => this.exportProfiles());
|
||||
this._bindClick("btn-recordings", () => this.recordings.openBrowser());
|
||||
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) {
|
||||
broadcastBtn.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this.selectBroadcastTargets();
|
||||
});
|
||||
}
|
||||
this._bindClick("btn-empty-connect", () => this.profileEditor.openNew());
|
||||
this._bindClick("btn-empty-quick", () => this.quickConnect());
|
||||
|
||||
@@ -565,6 +610,9 @@
|
||||
*/
|
||||
toggleBroadcast() {
|
||||
this.state.broadcastMode = !this.state.broadcastMode;
|
||||
if (!this.state.broadcastMode) {
|
||||
this.state.broadcastTargets = null;
|
||||
}
|
||||
const btn = document.getElementById("btn-broadcast");
|
||||
if (btn) {
|
||||
btn.classList.toggle("is-active", this.state.broadcastMode);
|
||||
@@ -579,6 +627,115 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal to select which sessions receive broadcast input.
|
||||
*/
|
||||
selectBroadcastTargets() {
|
||||
const { createElement, escapeHtml } = window.NexusTerminal;
|
||||
const sessions = this.state
|
||||
.getAllSessions()
|
||||
.filter(
|
||||
(s) => s.state === "connected" && s.id !== this.state.activeSessionId,
|
||||
);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
this.notifications.error("No other connected sessions to broadcast to");
|
||||
return;
|
||||
}
|
||||
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
const currentTargets = this.state.broadcastTargets || new Set();
|
||||
|
||||
let listHtml = "";
|
||||
for (const s of sessions) {
|
||||
const profile = this.state.getProfile(s.profileId);
|
||||
const label = profile
|
||||
? profile.displayName || profile.host
|
||||
: s.id.substring(0, 8);
|
||||
const checked =
|
||||
currentTargets.size === 0 || currentTargets.has(s.id)
|
||||
? "checked"
|
||||
: "";
|
||||
listHtml += `
|
||||
<label style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;font-size:var(--text-sm);cursor:pointer;">
|
||||
<input type="checkbox" class="broadcast-target-check" data-session-id="${s.id}" ${checked} />
|
||||
<span style="color:var(--text-primary);">${escapeHtml(label)}</span>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Broadcast Targets</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-2);">
|
||||
Select which sessions should receive broadcast input:
|
||||
</p>
|
||||
${listHtml}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-ghost broadcast-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-secondary broadcast-all">Select All</button>
|
||||
<button class="btn btn-sm btn-primary broadcast-apply">Apply & Enable</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
modal.querySelector(".broadcast-cancel").addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
modal.querySelector(".broadcast-all").addEventListener("click", () => {
|
||||
modal.querySelectorAll(".broadcast-target-check").forEach((cb) => {
|
||||
cb.checked = true;
|
||||
});
|
||||
});
|
||||
|
||||
modal.querySelector(".broadcast-apply").addEventListener("click", () => {
|
||||
const checks = modal.querySelectorAll(".broadcast-target-check");
|
||||
const selected = new Set();
|
||||
checks.forEach((cb) => {
|
||||
if (cb.checked) selected.add(cb.dataset.sessionId);
|
||||
});
|
||||
|
||||
if (selected.size === 0) {
|
||||
this.notifications.error("Select at least one target session");
|
||||
return;
|
||||
}
|
||||
|
||||
// If all are selected, use null (broadcast to all)
|
||||
this.state.broadcastTargets =
|
||||
selected.size === sessions.length ? null : selected;
|
||||
if (!this.state.broadcastMode) {
|
||||
this.toggleBroadcast();
|
||||
} else {
|
||||
const targetLabel =
|
||||
selected.size === sessions.length
|
||||
? "all sessions"
|
||||
: selected.size + " sessions";
|
||||
this.notifications.info("Broadcasting to " + targetLabel);
|
||||
}
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Connection lifecycle ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -634,6 +791,15 @@
|
||||
profile.displayName || profile.host,
|
||||
);
|
||||
}
|
||||
} else if (profile.protocol === "vnc") {
|
||||
const container = document.getElementById(panelId);
|
||||
if (container) {
|
||||
window.NexusTerminal.VNCViewer.createViewer(
|
||||
sessionInfo.id,
|
||||
container,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.terminals.createTerminal(sessionInfo.id, panelId, profile);
|
||||
}
|
||||
@@ -646,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.
|
||||
*/
|
||||
@@ -865,6 +1055,7 @@
|
||||
updateStatusBar() {
|
||||
const connEl = document.getElementById("status-connection");
|
||||
const countEl = document.getElementById("status-session-count");
|
||||
const latencyEl = document.getElementById("status-latency");
|
||||
const vaultEl = document.getElementById("status-vault");
|
||||
const updateEl = document.getElementById("status-update");
|
||||
if (!connEl) return;
|
||||
@@ -884,6 +1075,20 @@
|
||||
'<span class="status-dot is-disconnected"></span> No active session';
|
||||
}
|
||||
|
||||
// Latency indicator — only show for connected SSH sessions.
|
||||
if (latencyEl) {
|
||||
if (
|
||||
activeSession &&
|
||||
activeSession.state === "connected" &&
|
||||
activeSession.protocol === "ssh"
|
||||
) {
|
||||
this._startLatencyPing(activeSession.id);
|
||||
} else {
|
||||
this._stopLatencyPing();
|
||||
latencyEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (countEl) countEl.textContent = `${connected}/${total} sessions`;
|
||||
if (vaultEl) {
|
||||
vaultEl.textContent = this.state.secretStoreUnlocked
|
||||
@@ -905,6 +1110,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
_startLatencyPing(sessionID) {
|
||||
// Don't restart if already pinging the same session.
|
||||
if (this._latencySessionID === sessionID && this._latencyTimer) return;
|
||||
this._stopLatencyPing();
|
||||
this._latencySessionID = sessionID;
|
||||
|
||||
const doPing = async () => {
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||
try {
|
||||
const ms = await window.go.api.Service.PingSession(sessionID);
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el && this._latencySessionID === sessionID) {
|
||||
el.textContent = ms + " ms";
|
||||
el.className =
|
||||
ms < 100
|
||||
? "latency-good"
|
||||
: ms < 250
|
||||
? "latency-warn"
|
||||
: "latency-bad";
|
||||
}
|
||||
} catch (_) {
|
||||
// Session may have disconnected — clear indicator.
|
||||
const el = document.getElementById("status-latency");
|
||||
if (el) {
|
||||
el.textContent = "";
|
||||
el.className = "";
|
||||
}
|
||||
this._stopLatencyPing();
|
||||
}
|
||||
};
|
||||
|
||||
doPing();
|
||||
this._latencyTimer = setInterval(doPing, 10000);
|
||||
}
|
||||
|
||||
_stopLatencyPing() {
|
||||
if (this._latencyTimer) {
|
||||
clearInterval(this._latencyTimer);
|
||||
this._latencyTimer = null;
|
||||
}
|
||||
this._latencySessionID = null;
|
||||
}
|
||||
|
||||
async checkForUpdates(silent) {
|
||||
if (!window.go || !window.go.api || !window.go.api.Service) {
|
||||
return;
|
||||
@@ -1183,29 +1431,76 @@
|
||||
|
||||
const searchOpts = () => ({ caseSensitive, regex: useRegex });
|
||||
|
||||
let matchIndex = 0;
|
||||
let totalMatches = 0;
|
||||
|
||||
const updateCount = (found) => {
|
||||
if (!input.value) {
|
||||
count.textContent = "";
|
||||
matchIndex = 0;
|
||||
totalMatches = 0;
|
||||
return;
|
||||
}
|
||||
if (found) {
|
||||
matchIndex++;
|
||||
// Count total matches by searching the full buffer
|
||||
const entry = this.terminals.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
const buffer = entry.term.buffer.active;
|
||||
let total = 0;
|
||||
const query = input.value;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
if (useRegex) {
|
||||
try {
|
||||
const re = new RegExp(query, caseSensitive ? "g" : "gi");
|
||||
const m = text.match(re);
|
||||
if (m) total += m.length;
|
||||
} catch (_) {
|
||||
/* invalid regex */
|
||||
}
|
||||
} else {
|
||||
const searchText = caseSensitive ? text : text.toLowerCase();
|
||||
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
||||
let pos = 0;
|
||||
while ((pos = searchText.indexOf(searchQuery, pos)) !== -1) {
|
||||
total++;
|
||||
pos += searchQuery.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
totalMatches = total;
|
||||
if (matchIndex > totalMatches) matchIndex = 1;
|
||||
count.textContent = matchIndex + " of " + totalMatches;
|
||||
} else {
|
||||
count.textContent = "Match found";
|
||||
}
|
||||
} else {
|
||||
matchIndex = 0;
|
||||
totalMatches = 0;
|
||||
count.textContent = "No matches";
|
||||
}
|
||||
count.style.color = found ? "" : "var(--error)";
|
||||
};
|
||||
|
||||
const doSearch = () => {
|
||||
const found = this.terminals.search(
|
||||
sessionId,
|
||||
input.value,
|
||||
searchOpts(),
|
||||
);
|
||||
count.textContent = input.value
|
||||
? found
|
||||
? "Match found"
|
||||
: "No matches"
|
||||
: "";
|
||||
updateCount(found);
|
||||
};
|
||||
const doPrev = () => {
|
||||
matchIndex = Math.max(0, matchIndex - 2);
|
||||
const found = this.terminals.searchPrevious(
|
||||
sessionId,
|
||||
input.value,
|
||||
searchOpts(),
|
||||
);
|
||||
count.textContent = input.value
|
||||
? found
|
||||
? "Match found"
|
||||
: "No matches"
|
||||
: "";
|
||||
updateCount(found);
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
|
||||
+159
-100
@@ -1,13 +1,13 @@
|
||||
(function() {
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — command palette (Ctrl+K).
|
||||
* Provides a searchable overlay for quick access to application commands,
|
||||
* profile connections, and utility actions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, fuzzyMatch, $ } = window.NexusTerminal;
|
||||
|
||||
class CommandPalette {
|
||||
class CommandPalette {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
@@ -18,7 +18,7 @@ class CommandPalette {
|
||||
this._overlay = null;
|
||||
|
||||
/** @type {string} Current filter query. */
|
||||
this._query = '';
|
||||
this._query = "";
|
||||
|
||||
/** @type {number} Index of the highlighted command. */
|
||||
this._selectedIndex = 0;
|
||||
@@ -42,7 +42,7 @@ class CommandPalette {
|
||||
*/
|
||||
open() {
|
||||
if (this._overlay) return;
|
||||
this._query = '';
|
||||
this._query = "";
|
||||
this._selectedIndex = 0;
|
||||
this._render();
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class CommandPalette {
|
||||
cmds.push({
|
||||
id: `connect-${p.id}`,
|
||||
label: `Connect to ${p.displayName || p.host}`,
|
||||
section: 'Connections',
|
||||
section: "Connections",
|
||||
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
|
||||
action: () => this.app.connectProfile(p.id),
|
||||
});
|
||||
@@ -80,56 +80,64 @@ class CommandPalette {
|
||||
|
||||
// New connections
|
||||
cmds.push({
|
||||
id: 'new-ssh',
|
||||
label: 'New SSH Connection',
|
||||
section: 'Actions',
|
||||
shortcut: '⌘N',
|
||||
action: () => this.app.profileEditor.openNew('ssh'),
|
||||
id: "new-ssh",
|
||||
label: "New SSH Connection",
|
||||
section: "Actions",
|
||||
shortcut: "⌘N",
|
||||
action: () => this.app.profileEditor.openNew("ssh"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-telnet',
|
||||
label: 'New Telnet Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('telnet'),
|
||||
id: "new-telnet",
|
||||
label: "New Telnet Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("telnet"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'new-raw',
|
||||
label: 'New RAW Connection',
|
||||
section: 'Actions',
|
||||
action: () => this.app.profileEditor.openNew('raw'),
|
||||
id: "new-raw",
|
||||
label: "New RAW Connection",
|
||||
section: "Actions",
|
||||
action: () => this.app.profileEditor.openNew("raw"),
|
||||
});
|
||||
|
||||
// Quick connect
|
||||
cmds.push({
|
||||
id: 'quick-connect',
|
||||
label: 'Quick Connect…',
|
||||
section: 'Actions',
|
||||
id: "quick-connect",
|
||||
label: "Quick Connect…",
|
||||
section: "Actions",
|
||||
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',
|
||||
label: 'Close Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘W',
|
||||
id: "close-tab",
|
||||
label: "Close Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘W",
|
||||
action: () => this.app.tabs.closeActive(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'next-tab',
|
||||
label: 'Next Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘Tab',
|
||||
id: "next-tab",
|
||||
label: "Next Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘Tab",
|
||||
action: () => {
|
||||
const next = this.app.tabs.getNextTab();
|
||||
if (next) this.app.tabs.setActive(next);
|
||||
},
|
||||
});
|
||||
cmds.push({
|
||||
id: 'prev-tab',
|
||||
label: 'Previous Tab',
|
||||
section: 'Tabs',
|
||||
shortcut: '⌘⇧Tab',
|
||||
id: "prev-tab",
|
||||
label: "Previous Tab",
|
||||
section: "Tabs",
|
||||
shortcut: "⌘⇧Tab",
|
||||
action: () => {
|
||||
const prev = this.app.tabs.getPrevTab();
|
||||
if (prev) this.app.tabs.setActive(prev);
|
||||
@@ -138,73 +146,106 @@ class CommandPalette {
|
||||
|
||||
// Settings & utility
|
||||
cmds.push({
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
section: 'Application',
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
section: "Application",
|
||||
action: () => this.app.settings.open(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'import-profiles',
|
||||
label: 'Import Profiles',
|
||||
section: 'Application',
|
||||
id: "import-profiles",
|
||||
label: "Import Profiles",
|
||||
section: "Application",
|
||||
action: () => this.app.importProfiles(),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'export-profiles',
|
||||
label: 'Export Profiles',
|
||||
section: 'Application',
|
||||
id: "export-profiles",
|
||||
label: "Export Profiles",
|
||||
section: "Application",
|
||||
action: () => this.app.exportProfiles(),
|
||||
});
|
||||
|
||||
// Secret store
|
||||
if (this.app.state.secretStoreUnlocked) {
|
||||
cmds.push({
|
||||
id: 'lock-secrets',
|
||||
label: 'Lock Secret Store',
|
||||
section: 'Security',
|
||||
id: "lock-secrets",
|
||||
label: "Lock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
await window.go.api.Service.LockSecretStore();
|
||||
this.app.state.setSecretStoreState(false);
|
||||
this.app.notifications.info('Secret store locked');
|
||||
this.app.notifications.info("Secret store locked");
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to lock: ' + err);
|
||||
this.app.notifications.error("Failed to lock: " + err);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
cmds.push({
|
||||
id: 'unlock-secrets',
|
||||
label: 'Unlock Secret Store',
|
||||
section: 'Security',
|
||||
id: "unlock-secrets",
|
||||
label: "Unlock Secret Store",
|
||||
section: "Security",
|
||||
action: async () => {
|
||||
try {
|
||||
const pw = await this.app.secretPrompt.promptMasterPassword(false);
|
||||
const pw =
|
||||
await this.app.secretPrompt.promptMasterPassword(false);
|
||||
await window.go.api.Service.UnlockSecretStore(pw);
|
||||
this.app.state.setSecretStoreState(true);
|
||||
this.app.notifications.success('Secret store unlocked');
|
||||
this.app.notifications.success("Secret store unlocked");
|
||||
} catch (err) {
|
||||
if (err.message !== 'User cancelled') {
|
||||
this.app.notifications.error('Unlock failed: ' + err);
|
||||
if (err.message !== "User cancelled") {
|
||||
this.app.notifications.error("Unlock failed: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// SSH Key Manager
|
||||
cmds.push({
|
||||
id: "ssh-keys",
|
||||
label: "SSH Key Manager",
|
||||
section: "Security",
|
||||
action: () => this.app.sshKeys.open(),
|
||||
});
|
||||
|
||||
// Trigger Rules
|
||||
cmds.push({
|
||||
id: "trigger-rules",
|
||||
label: "Trigger Rules",
|
||||
section: "Terminal",
|
||||
action: () => this.app.triggers.open(),
|
||||
});
|
||||
|
||||
// Terminal actions (only when a session is active)
|
||||
if (this.app.state.activeSessionId) {
|
||||
cmds.push({
|
||||
id: 'clear-terminal',
|
||||
label: 'Clear Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||
id: "split-vertical",
|
||||
label: "Split Pane Right",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧D",
|
||||
action: () => this.app.splitPane("vertical"),
|
||||
});
|
||||
cmds.push({
|
||||
id: 'reset-terminal',
|
||||
label: 'Reset Terminal',
|
||||
section: 'Terminal',
|
||||
action: () => this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||
id: "split-horizontal",
|
||||
label: "Split Pane Down",
|
||||
section: "Terminal",
|
||||
shortcut: "⌘⇧E",
|
||||
action: () => this.app.splitPane("horizontal"),
|
||||
});
|
||||
cmds.push({
|
||||
id: "clear-terminal",
|
||||
label: "Clear Terminal",
|
||||
section: "Terminal",
|
||||
action: () =>
|
||||
this.app.terminals.clearTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
cmds.push({
|
||||
id: "reset-terminal",
|
||||
label: "Reset Terminal",
|
||||
section: "Terminal",
|
||||
action: () =>
|
||||
this.app.terminals.resetTerminal(this.app.state.activeSessionId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,7 +261,10 @@ class CommandPalette {
|
||||
if (!query) return this.getCommands();
|
||||
return this.getCommands()
|
||||
.map((cmd) => {
|
||||
const result = fuzzyMatch(cmd.label + ' ' + (cmd.detail || ''), query);
|
||||
const result = fuzzyMatch(
|
||||
cmd.label + " " + (cmd.detail || ""),
|
||||
query,
|
||||
);
|
||||
return { ...cmd, ...result };
|
||||
})
|
||||
.filter((c) => c.match)
|
||||
@@ -233,7 +277,7 @@ class CommandPalette {
|
||||
*/
|
||||
execute(command) {
|
||||
this.close();
|
||||
if (command && typeof command.action === 'function') {
|
||||
if (command && typeof command.action === "function") {
|
||||
command.action();
|
||||
}
|
||||
}
|
||||
@@ -242,21 +286,21 @@ class CommandPalette {
|
||||
|
||||
/** @private */
|
||||
_render() {
|
||||
const overlay = createElement('div', 'palette-overlay');
|
||||
const palette = createElement('div', 'palette');
|
||||
const overlay = createElement("div", "palette-overlay");
|
||||
const palette = createElement("div", "palette");
|
||||
|
||||
// Search input
|
||||
const input = createElement('input', 'palette-input', {
|
||||
type: 'text',
|
||||
placeholder: 'Type a command…',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
const input = createElement("input", "palette-input", {
|
||||
type: "text",
|
||||
placeholder: "Type a command…",
|
||||
autocomplete: "off",
|
||||
spellcheck: "false",
|
||||
});
|
||||
|
||||
palette.appendChild(input);
|
||||
|
||||
// Results list
|
||||
const list = createElement('div', 'palette-list');
|
||||
const list = createElement("div", "palette-list");
|
||||
palette.appendChild(list);
|
||||
|
||||
overlay.appendChild(palette);
|
||||
@@ -264,32 +308,35 @@ class CommandPalette {
|
||||
this._overlay = overlay;
|
||||
|
||||
// Initial render
|
||||
this._renderList(list, this.filter(''));
|
||||
this._renderList(list, this.filter(""));
|
||||
|
||||
// Focus the input
|
||||
input.focus();
|
||||
|
||||
// Event handlers
|
||||
input.addEventListener('input', () => {
|
||||
input.addEventListener("input", () => {
|
||||
this._query = input.value;
|
||||
this._selectedIndex = 0;
|
||||
this._renderList(list, this.filter(this._query));
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = list.querySelectorAll('.palette-item');
|
||||
input.addEventListener("keydown", (e) => {
|
||||
const items = list.querySelectorAll(".palette-item");
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.min(this._selectedIndex + 1, items.length - 1);
|
||||
this._selectedIndex = Math.min(
|
||||
this._selectedIndex + 1,
|
||||
items.length - 1,
|
||||
);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
|
||||
this._highlightItem(items);
|
||||
break;
|
||||
case 'Enter': {
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
const selected = items[this._selectedIndex];
|
||||
if (selected && selected._command) {
|
||||
@@ -297,7 +344,7 @@ class CommandPalette {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
@@ -305,7 +352,7 @@ class CommandPalette {
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
overlay.addEventListener('click', (e) => {
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) this.close();
|
||||
});
|
||||
}
|
||||
@@ -317,11 +364,13 @@ class CommandPalette {
|
||||
* @private
|
||||
*/
|
||||
_renderList(list, commands) {
|
||||
list.innerHTML = '';
|
||||
list.innerHTML = "";
|
||||
let currentSection = null;
|
||||
|
||||
if (commands.length === 0) {
|
||||
const empty = createElement('div', 'palette-empty', { textContent: 'No commands found' });
|
||||
const empty = createElement("div", "palette-empty", {
|
||||
textContent: "No commands found",
|
||||
});
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -332,33 +381,44 @@ class CommandPalette {
|
||||
// Section header
|
||||
if (cmd.section && cmd.section !== currentSection) {
|
||||
currentSection = cmd.section;
|
||||
const sectionEl = createElement('div', 'palette-section', { textContent: cmd.section });
|
||||
const sectionEl = createElement("div", "palette-section", {
|
||||
textContent: cmd.section,
|
||||
});
|
||||
list.appendChild(sectionEl);
|
||||
}
|
||||
|
||||
const item = createElement('div', `palette-item ${i === this._selectedIndex ? 'palette-item-active' : ''}`);
|
||||
const item = createElement(
|
||||
"div",
|
||||
`palette-item ${i === this._selectedIndex ? "palette-item-active" : ""}`,
|
||||
);
|
||||
item._command = cmd;
|
||||
|
||||
// Label
|
||||
const label = createElement('span', 'palette-item-label', { textContent: cmd.label });
|
||||
const label = createElement("span", "palette-item-label", {
|
||||
textContent: cmd.label,
|
||||
});
|
||||
item.appendChild(label);
|
||||
|
||||
// Detail
|
||||
if (cmd.detail) {
|
||||
const detail = createElement('span', 'palette-item-detail', { textContent: cmd.detail });
|
||||
const detail = createElement("span", "palette-item-detail", {
|
||||
textContent: cmd.detail,
|
||||
});
|
||||
item.appendChild(detail);
|
||||
}
|
||||
|
||||
// Shortcut badge
|
||||
if (cmd.shortcut) {
|
||||
const badge = createElement('span', 'palette-item-shortcut', { textContent: cmd.shortcut });
|
||||
const badge = createElement("span", "palette-item-shortcut", {
|
||||
textContent: cmd.shortcut,
|
||||
});
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => this.execute(cmd));
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.addEventListener("click", () => this.execute(cmd));
|
||||
item.addEventListener("mouseenter", () => {
|
||||
this._selectedIndex = i;
|
||||
this._highlightItem(list.querySelectorAll('.palette-item'));
|
||||
this._highlightItem(list.querySelectorAll(".palette-item"));
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
@@ -372,16 +432,15 @@ class CommandPalette {
|
||||
*/
|
||||
_highlightItem(items) {
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle('palette-item-active', i === this._selectedIndex);
|
||||
el.classList.toggle("palette-item-active", i === this._selectedIndex);
|
||||
});
|
||||
const active = items[this._selectedIndex];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
active.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.CommandPalette = CommandPalette;
|
||||
})();
|
||||
|
||||
+185
-3
@@ -65,6 +65,27 @@
|
||||
refreshIntervalSecs: 300,
|
||||
},
|
||||
},
|
||||
vnc: {
|
||||
port: 5900,
|
||||
protocol: "vnc",
|
||||
vnc: {
|
||||
vncHost: "",
|
||||
vncPort: 5900,
|
||||
secretMode: "prompt",
|
||||
viewOnly: false,
|
||||
scaleToFit: true,
|
||||
quality: 6,
|
||||
sharedSession: true,
|
||||
sshTunnel: false,
|
||||
sshHost: "",
|
||||
sshPort: 22,
|
||||
sshUsername: "",
|
||||
sshAuthMethod: "publickey",
|
||||
sshSecretMode: "saved",
|
||||
sshPrivateKey: "",
|
||||
sshAgentAuth: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class ProfileEditor {
|
||||
@@ -141,7 +162,7 @@
|
||||
drawer.appendChild(header);
|
||||
|
||||
// ── Protocol selector tabs ──────────────────────────────
|
||||
const protocols = ["ssh", "telnet", "raw", "github"];
|
||||
const protocols = ["ssh", "telnet", "raw", "github", "vnc"];
|
||||
const tabBar = createElement("div", "drawer-protocol-tabs");
|
||||
for (const proto of protocols) {
|
||||
const label = proto === "github" ? "GitHub" : proto.toUpperCase();
|
||||
@@ -184,10 +205,17 @@
|
||||
body.appendChild(s),
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
this._renderVNCSections(profile).forEach((s) => body.appendChild(s));
|
||||
break;
|
||||
}
|
||||
|
||||
// Terminal section (SSH and Telnet only)
|
||||
if (profile.protocol !== "raw" && profile.protocol !== "github") {
|
||||
if (
|
||||
profile.protocol !== "raw" &&
|
||||
profile.protocol !== "github" &&
|
||||
profile.protocol !== "vnc"
|
||||
) {
|
||||
body.appendChild(this._renderTerminalSection(profile));
|
||||
}
|
||||
|
||||
@@ -922,6 +950,131 @@
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_renderVNCSections(profile) {
|
||||
const sections = [];
|
||||
const vnc = profile.vnc || DEFAULTS.vnc.vnc;
|
||||
|
||||
// VNC connection settings
|
||||
sections.push(
|
||||
this._renderSection("VNC Server", [
|
||||
this._field("text", "vnc.vncHost", "VNC Host", vnc.vncHost, {
|
||||
placeholder: "192.168.1.100",
|
||||
}),
|
||||
this._field("number", "vnc.vncPort", "VNC Port", vnc.vncPort, {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
}),
|
||||
this._field(
|
||||
"select",
|
||||
"vnc.secretMode",
|
||||
"Password Storage",
|
||||
vnc.secretMode,
|
||||
{
|
||||
options: [
|
||||
{ value: "saved", label: "Save to keychain" },
|
||||
{ value: "session-only", label: "Remember for session" },
|
||||
{ value: "prompt", label: "Ask every time" },
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
// Display settings
|
||||
sections.push(
|
||||
this._renderSection("Display", [
|
||||
this._field("checkbox", "vnc.viewOnly", "View Only", vnc.viewOnly),
|
||||
this._field(
|
||||
"checkbox",
|
||||
"vnc.scaleToFit",
|
||||
"Scale to Fit",
|
||||
vnc.scaleToFit,
|
||||
),
|
||||
this._field(
|
||||
"checkbox",
|
||||
"vnc.sharedSession",
|
||||
"Shared Session",
|
||||
vnc.sharedSession,
|
||||
),
|
||||
this._field("number", "vnc.quality", "Quality (1-9)", vnc.quality, {
|
||||
min: 1,
|
||||
max: 9,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// SSH tunnel section
|
||||
sections.push(
|
||||
this._renderSection("SSH Tunnel", [
|
||||
this._field(
|
||||
"checkbox",
|
||||
"vnc.sshTunnel",
|
||||
"Enable SSH Tunnel",
|
||||
vnc.sshTunnel,
|
||||
),
|
||||
this._field("text", "vnc.sshHost", "SSH Host", vnc.sshHost, {
|
||||
placeholder: "jump.example.com",
|
||||
}),
|
||||
this._field("number", "vnc.sshPort", "SSH Port", vnc.sshPort, {
|
||||
min: 1,
|
||||
max: 65535,
|
||||
}),
|
||||
this._field(
|
||||
"text",
|
||||
"vnc.sshUsername",
|
||||
"SSH Username",
|
||||
vnc.sshUsername,
|
||||
{
|
||||
placeholder: "admin",
|
||||
},
|
||||
),
|
||||
this._field(
|
||||
"select",
|
||||
"vnc.sshAuthMethod",
|
||||
"SSH Auth Method",
|
||||
vnc.sshAuthMethod,
|
||||
{
|
||||
options: [
|
||||
{ value: "publickey", label: "Public Key" },
|
||||
{ value: "password", label: "Password" },
|
||||
],
|
||||
},
|
||||
),
|
||||
this._field(
|
||||
"text",
|
||||
"vnc.sshPrivateKey",
|
||||
"SSH Private Key Path",
|
||||
vnc.sshPrivateKey,
|
||||
{
|
||||
placeholder: "~/.ssh/id_ed25519",
|
||||
},
|
||||
),
|
||||
this._field(
|
||||
"checkbox",
|
||||
"vnc.sshAgentAuth",
|
||||
"Use SSH Agent",
|
||||
vnc.sshAgentAuth,
|
||||
),
|
||||
this._field(
|
||||
"select",
|
||||
"vnc.sshSecretMode",
|
||||
"SSH Secret Storage",
|
||||
vnc.sshSecretMode,
|
||||
{
|
||||
options: [
|
||||
{ value: "saved", label: "Save to keychain" },
|
||||
{ value: "session-only", label: "Remember for session" },
|
||||
{ value: "prompt", label: "Ask every time" },
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_renderTerminalSection(profile) {
|
||||
return this._renderSection("Terminal", [
|
||||
@@ -2102,6 +2255,26 @@
|
||||
p.github.secretMode = val("github.secretMode") || "saved";
|
||||
p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300;
|
||||
}
|
||||
|
||||
// VNC-specific
|
||||
if (p.protocol === "vnc") {
|
||||
if (!p.vnc) p.vnc = {};
|
||||
p.vnc.vncHost = val("vnc.vncHost") || "";
|
||||
p.vnc.vncPort = val("vnc.vncPort") || 5900;
|
||||
p.vnc.secretMode = val("vnc.secretMode") || "prompt";
|
||||
p.vnc.viewOnly = val("vnc.viewOnly") || false;
|
||||
p.vnc.scaleToFit = val("vnc.scaleToFit") !== false;
|
||||
p.vnc.quality = val("vnc.quality") || 6;
|
||||
p.vnc.sharedSession = val("vnc.sharedSession") !== false;
|
||||
p.vnc.sshTunnel = val("vnc.sshTunnel") || false;
|
||||
p.vnc.sshHost = val("vnc.sshHost") || "";
|
||||
p.vnc.sshPort = val("vnc.sshPort") || 22;
|
||||
p.vnc.sshUsername = val("vnc.sshUsername") || "";
|
||||
p.vnc.sshAuthMethod = val("vnc.sshAuthMethod") || "publickey";
|
||||
p.vnc.sshPrivateKey = val("vnc.sshPrivateKey") || "";
|
||||
p.vnc.sshAgentAuth = val("vnc.sshAgentAuth") || false;
|
||||
p.vnc.sshSecretMode = val("vnc.sshSecretMode") || "saved";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
@@ -2114,7 +2287,14 @@
|
||||
*/
|
||||
_validate(profile) {
|
||||
const errors = [];
|
||||
if (!profile.host || !profile.host.trim()) {
|
||||
if (profile.protocol === "vnc") {
|
||||
const vncHost = (profile.vnc && profile.vnc.vncHost) || profile.host;
|
||||
if (!vncHost || !vncHost.trim()) {
|
||||
errors.push(
|
||||
"VNC Host is required (in General or VNC Server section).",
|
||||
);
|
||||
}
|
||||
} else if (!profile.host || !profile.host.trim()) {
|
||||
errors.push("Host is required.");
|
||||
}
|
||||
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
||||
@@ -2283,6 +2463,8 @@
|
||||
ssh: null,
|
||||
telnet: null,
|
||||
raw: null,
|
||||
github: null,
|
||||
vnc: null,
|
||||
};
|
||||
|
||||
const defaults = DEFAULTS[protocol] || {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+412
-18
@@ -231,6 +231,16 @@
|
||||
|
||||
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);
|
||||
@@ -426,7 +436,7 @@
|
||||
if (!isParent) {
|
||||
row.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
self._selectFile(sessionId, entry, row);
|
||||
self._selectFile(sessionId, entry, row, e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,52 +478,104 @@
|
||||
|
||||
/* ── Selection & context actions ──────────────────────────────── */
|
||||
|
||||
_selectFile(sessionId, entry, rowEl) {
|
||||
_selectFile(sessionId, entry, rowEl, event) {
|
||||
var state = this.panels.get(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
// Deselect previous
|
||||
var prev = state.el.querySelector(".sftp-file-row.is-selected");
|
||||
if (prev) prev.classList.remove("is-selected");
|
||||
// Initialise multi-select set if needed
|
||||
if (!state.selectedFiles) state.selectedFiles = new Map();
|
||||
|
||||
// If clicking the same entry, deselect
|
||||
if (state.selected === entry.path) {
|
||||
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.selected = entry.path;
|
||||
state.selectedFiles.clear();
|
||||
state.selectedFiles.set(entry.path, entry);
|
||||
rowEl.classList.add("is-selected");
|
||||
this._showContextBar(sessionId, entry);
|
||||
}
|
||||
|
||||
_showContextBar(sessionId, entry) {
|
||||
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: entry.name,
|
||||
title: entry.path,
|
||||
textContent: isSingle ? entry.name : count + " items selected",
|
||||
title: isSingle
|
||||
? entry.path
|
||||
: entries
|
||||
.map(function (e) {
|
||||
return e.name;
|
||||
})
|
||||
.join(", "),
|
||||
});
|
||||
ctxBar.appendChild(label);
|
||||
|
||||
var self = this;
|
||||
|
||||
if (!entry.isDir) {
|
||||
// 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: "↓ Download",
|
||||
textContent: dlLabel,
|
||||
});
|
||||
dlBtn.addEventListener("click", function () {
|
||||
self._download(sessionId, entry.path);
|
||||
self._downloadBatch(sessionId, downloadableFiles);
|
||||
});
|
||||
ctxBar.appendChild(dlBtn);
|
||||
}
|
||||
|
||||
if (isSingle) {
|
||||
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||
textContent: "✎ Rename",
|
||||
});
|
||||
@@ -521,12 +583,18 @@
|
||||
self._promptRename(sessionId, entry);
|
||||
});
|
||||
ctxBar.appendChild(renameBtn);
|
||||
}
|
||||
|
||||
var delLabel = isSingle ? "✕ Delete" : "✕ Delete (" + count + ")";
|
||||
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
|
||||
textContent: "✕ Delete",
|
||||
textContent: delLabel,
|
||||
});
|
||||
delBtn.addEventListener("click", function () {
|
||||
if (isSingle) {
|
||||
self._confirmDelete(sessionId, entry);
|
||||
} else {
|
||||
self._confirmDeleteBatch(sessionId, entries);
|
||||
}
|
||||
});
|
||||
ctxBar.appendChild(delBtn);
|
||||
}
|
||||
@@ -598,6 +666,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
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.go.api.Service.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.go.api.Service.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) {
|
||||
@@ -784,6 +905,279 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+227
-79
@@ -1,12 +1,13 @@
|
||||
(function() {
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — snippet / command library.
|
||||
* Provides a panel for managing and sending reusable commands to active sessions.
|
||||
*/
|
||||
|
||||
const { createElement, escapeHtml, dismissContextMenus } = window.NexusTerminal;
|
||||
const { createElement, escapeHtml, dismissContextMenus } =
|
||||
window.NexusTerminal;
|
||||
|
||||
class SnippetLibrary {
|
||||
class SnippetLibrary {
|
||||
/**
|
||||
* @param {App} app
|
||||
*/
|
||||
@@ -14,7 +15,7 @@ class SnippetLibrary {
|
||||
this.app = app;
|
||||
/** @type {Array<Object>} */
|
||||
this.snippets = [];
|
||||
this.searchQuery = '';
|
||||
this.searchQuery = "";
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
@@ -29,26 +30,140 @@ class SnippetLibrary {
|
||||
|
||||
/**
|
||||
* Sends a snippet command to the active session.
|
||||
* Supports {{variable}} placeholders that prompt the user for values.
|
||||
* Built-in variables: {{hostname}}, {{date}}, {{time}}, {{timestamp}}.
|
||||
* @param {string} command
|
||||
*/
|
||||
sendToActiveSession(command) {
|
||||
const sessionId = this.app.state.activeSessionId;
|
||||
if (!sessionId) {
|
||||
this.app.notifications.error('No active session');
|
||||
this.app.notifications.error("No active session");
|
||||
return;
|
||||
}
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || session.state !== 'connected') {
|
||||
this.app.notifications.error('Session is not connected');
|
||||
if (!session || session.state !== "connected") {
|
||||
this.app.notifications.error("Session is not connected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const b64 = btoa(command + '\n');
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info('Snippet sent');
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to send snippet: ' + err);
|
||||
|
||||
// Check for template variables
|
||||
const varPattern = /\{\{([^}]+)\}\}/g;
|
||||
const matches = [];
|
||||
let match;
|
||||
while ((match = varPattern.exec(command)) !== null) {
|
||||
matches.push(match[1].trim());
|
||||
}
|
||||
|
||||
// Resolve built-in variables first
|
||||
const builtins = {
|
||||
hostname: session.host || "unknown",
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
time: new Date().toTimeString().split(" ")[0],
|
||||
timestamp: Math.floor(Date.now() / 1000).toString(),
|
||||
};
|
||||
|
||||
let resolved = command;
|
||||
const userVars = [];
|
||||
for (const varName of matches) {
|
||||
const lower = varName.toLowerCase();
|
||||
if (builtins[lower] !== undefined) {
|
||||
resolved = resolved.replace("{{" + varName + "}}", builtins[lower]);
|
||||
} else if (!userVars.includes(varName)) {
|
||||
userVars.push(varName);
|
||||
}
|
||||
}
|
||||
|
||||
if (userVars.length === 0) {
|
||||
this._sendCommand(sessionId, resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for user-defined variables
|
||||
this._promptVariables(userVars, (values) => {
|
||||
let final = resolved;
|
||||
for (let i = 0; i < userVars.length; i++) {
|
||||
final = final.split("{{" + userVars[i] + "}}").join(values[i] || "");
|
||||
}
|
||||
this._sendCommand(sessionId, final);
|
||||
});
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_sendCommand(sessionId, command) {
|
||||
try {
|
||||
const b64 = btoa(command + "\n");
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
this.app.notifications.info("Snippet sent");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to send snippet: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_promptVariables(varNames, onConfirm) {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
|
||||
let inputsHtml = "";
|
||||
for (const v of varNames) {
|
||||
inputsHtml += `
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">${escapeHtml(v)}</label>
|
||||
<input type="text" class="form-input snippet-var-input" data-var="${escapeHtml(v)}" placeholder="Enter value for ${escapeHtml(v)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Snippet Variables</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">${inputsHtml}</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm btn-ghost snippet-var-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary snippet-var-send">Send</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
const firstInput = modal.querySelector(".snippet-var-input");
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
modal
|
||||
.querySelector(".snippet-var-cancel")
|
||||
.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
modal.querySelector(".snippet-var-send").addEventListener("click", () => {
|
||||
const inputs = modal.querySelectorAll(".snippet-var-input");
|
||||
const values = Array.from(inputs).map((inp) => inp.value);
|
||||
close();
|
||||
onConfirm(values);
|
||||
});
|
||||
|
||||
// Enter to send
|
||||
modal.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
modal.querySelector(".snippet-var-send").click();
|
||||
}
|
||||
if (e.key === "Escape") close();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────
|
||||
@@ -56,10 +171,10 @@ class SnippetLibrary {
|
||||
/** @private */
|
||||
async _loadSnippets() {
|
||||
try {
|
||||
this.snippets = await window.go.api.Service.ListSnippets() || [];
|
||||
this.snippets = (await window.go.api.Service.ListSnippets()) || [];
|
||||
} catch (err) {
|
||||
this.snippets = [];
|
||||
console.error('Failed to load snippets:', err);
|
||||
console.error("Failed to load snippets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +182,8 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_showModal() {
|
||||
const backdrop = createElement('div', 'modal-backdrop');
|
||||
const modal = createElement('div', 'modal modal-lg');
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
@@ -94,28 +209,30 @@ class SnippetLibrary {
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add('is-visible');
|
||||
modal.classList.add('is-visible');
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove('is-visible');
|
||||
modal.classList.remove('is-visible');
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector('.modal-close').addEventListener('click', close);
|
||||
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
// Search
|
||||
const searchInput = modal.querySelector('.snippet-search');
|
||||
searchInput.addEventListener('input', () => {
|
||||
const searchInput = modal.querySelector(".snippet-search");
|
||||
searchInput.addEventListener("input", () => {
|
||||
this.searchQuery = searchInput.value.trim().toLowerCase();
|
||||
this._renderList(modal, close);
|
||||
});
|
||||
|
||||
// New snippet button
|
||||
modal.querySelector('.snippet-add-btn').addEventListener('click', () => {
|
||||
modal.querySelector(".snippet-add-btn").addEventListener("click", () => {
|
||||
this._showEditor(modal, null, close);
|
||||
});
|
||||
|
||||
@@ -124,87 +241,106 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_renderList(modal, closeFn) {
|
||||
const listEl = modal.querySelector('.snippet-list');
|
||||
const listEl = modal.querySelector(".snippet-list");
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '';
|
||||
listEl.innerHTML = "";
|
||||
|
||||
const filtered = this.snippets.filter((sn) => {
|
||||
if (!this.searchQuery) return true;
|
||||
return sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
return (
|
||||
sn.name.toLowerCase().includes(this.searchQuery) ||
|
||||
sn.command.toLowerCase().includes(this.searchQuery) ||
|
||||
(sn.description || '').toLowerCase().includes(this.searchQuery);
|
||||
(sn.description || "").toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-3);text-align:center;">No snippets found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sn of filtered) {
|
||||
const item = createElement('div', 'snippet-item', {
|
||||
const item = createElement("div", "snippet-item", {
|
||||
dataset: { id: sn.id },
|
||||
});
|
||||
item.style.cssText = 'padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);';
|
||||
item.style.cssText =
|
||||
"padding:var(--space-2);border-radius:var(--radius-md);cursor:pointer;border:1px solid var(--border-subtle);";
|
||||
item.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);color:var(--text-primary);">${escapeHtml(sn.name)}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-secondary);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(sn.command)}</div>
|
||||
`;
|
||||
item.addEventListener('click', () => this._showDetail(modal, sn, closeFn));
|
||||
item.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
item.addEventListener('mouseenter', () => { item.style.background = 'var(--bg-hover)'; });
|
||||
item.addEventListener('mouseleave', () => { item.style.background = ''; });
|
||||
item.addEventListener("click", () =>
|
||||
this._showDetail(modal, sn, closeFn),
|
||||
);
|
||||
item.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
item.addEventListener("mouseenter", () => {
|
||||
item.style.background = "var(--bg-hover)";
|
||||
});
|
||||
item.addEventListener("mouseleave", () => {
|
||||
item.style.background = "";
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_showDetail(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector('.snippet-detail-panel');
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const nameEl = createElement('div', '', {
|
||||
const nameEl = createElement("div", "", {
|
||||
innerHTML: `<strong style="font-size:var(--text-base);">${escapeHtml(snippet.name)}</strong>`,
|
||||
});
|
||||
detailPanel.appendChild(nameEl);
|
||||
|
||||
if (snippet.description) {
|
||||
const descEl = createElement('div', '', {
|
||||
const descEl = createElement("div", "", {
|
||||
textContent: snippet.description,
|
||||
});
|
||||
descEl.style.cssText = 'font-size:var(--text-sm);color:var(--text-secondary);';
|
||||
descEl.style.cssText =
|
||||
"font-size:var(--text-sm);color:var(--text-secondary);";
|
||||
detailPanel.appendChild(descEl);
|
||||
}
|
||||
|
||||
const cmdEl = createElement('pre', '', {
|
||||
const cmdEl = createElement("pre", "", {
|
||||
textContent: snippet.command,
|
||||
});
|
||||
cmdEl.style.cssText = 'flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;';
|
||||
cmdEl.style.cssText =
|
||||
"flex:1;font-family:var(--font-mono);font-size:var(--text-sm);background:var(--bg-base);padding:var(--space-3);border-radius:var(--radius-md);overflow:auto;color:var(--text-primary);margin:0;white-space:pre-wrap;word-break:break-all;";
|
||||
detailPanel.appendChild(cmdEl);
|
||||
|
||||
// Action buttons
|
||||
const actions = createElement('div', '', {});
|
||||
actions.style.cssText = 'display:flex;gap:var(--space-2);';
|
||||
const actions = createElement("div", "", {});
|
||||
actions.style.cssText = "display:flex;gap:var(--space-2);";
|
||||
|
||||
const sendBtn = createElement('button', 'btn btn-sm btn-primary', { textContent: 'Send to Session' });
|
||||
sendBtn.addEventListener('click', () => {
|
||||
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
|
||||
textContent: "Send to Session",
|
||||
});
|
||||
sendBtn.addEventListener("click", () => {
|
||||
this.sendToActiveSession(snippet.command);
|
||||
});
|
||||
|
||||
const editBtn = createElement('button', 'btn btn-sm btn-secondary', { textContent: 'Edit' });
|
||||
editBtn.addEventListener('click', () => {
|
||||
const editBtn = createElement("button", "btn btn-sm btn-secondary", {
|
||||
textContent: "Edit",
|
||||
});
|
||||
editBtn.addEventListener("click", () => {
|
||||
this._showEditor(modal, snippet, closeFn);
|
||||
});
|
||||
|
||||
const deleteBtn = createElement('button', 'btn btn-sm btn-danger', { textContent: 'Delete' });
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
const deleteBtn = createElement("button", "btn btn-sm btn-danger", {
|
||||
textContent: "Delete",
|
||||
});
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.go.api.Service.DeleteSnippet(snippet.id);
|
||||
this.snippets = this.snippets.filter((s) => s.id !== snippet.id);
|
||||
this._renderList(modal, closeFn);
|
||||
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Snippet deleted</div>';
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to delete: ' + err);
|
||||
this.app.notifications.error("Failed to delete: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,30 +352,30 @@ class SnippetLibrary {
|
||||
|
||||
/** @private */
|
||||
_showEditor(modal, snippet, closeFn) {
|
||||
const detailPanel = modal.querySelector('.snippet-detail-panel');
|
||||
const detailPanel = modal.querySelector(".snippet-detail-panel");
|
||||
if (!detailPanel) return;
|
||||
detailPanel.innerHTML = '';
|
||||
detailPanel.innerHTML = "";
|
||||
|
||||
const isNew = !snippet;
|
||||
const title = isNew ? 'New Snippet' : 'Edit Snippet';
|
||||
const title = isNew ? "New Snippet" : "Edit Snippet";
|
||||
|
||||
detailPanel.innerHTML = `
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);">${title}</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-input snippet-ed-name" value="${isNew ? '' : escapeHtml(snippet.name)}" />
|
||||
<input type="text" class="form-input snippet-ed-name" value="${isNew ? "" : escapeHtml(snippet.name)}" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? '' : escapeHtml(snippet.description || '')}" />
|
||||
<input type="text" class="form-input snippet-ed-desc" value="${isNew ? "" : escapeHtml(snippet.description || "")}" />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;display:flex;flex-direction:column;">
|
||||
<label class="form-label">Command</label>
|
||||
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? '' : escapeHtml(snippet.command)}</textarea>
|
||||
<textarea class="form-input snippet-ed-cmd" style="flex:1;font-family:var(--font-mono);font-size:var(--text-sm);resize:none;">${isNew ? "" : escapeHtml(snippet.command)}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:var(--space-2);">
|
||||
<label class="form-label">Folder</label>
|
||||
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? '' : escapeHtml(snippet.folder || '')}" placeholder="e.g. Networking, Debugging" />
|
||||
<input type="text" class="form-input snippet-ed-folder" value="${isNew ? "" : escapeHtml(snippet.folder || "")}" placeholder="e.g. Networking, Debugging" />
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
|
||||
@@ -247,29 +383,37 @@ class SnippetLibrary {
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailPanel.querySelector('.snippet-save-btn').addEventListener('click', async () => {
|
||||
const name = detailPanel.querySelector('.snippet-ed-name').value.trim();
|
||||
const command = detailPanel.querySelector('.snippet-ed-cmd').value;
|
||||
const description = detailPanel.querySelector('.snippet-ed-desc').value.trim();
|
||||
const folder = detailPanel.querySelector('.snippet-ed-folder').value.trim();
|
||||
detailPanel
|
||||
.querySelector(".snippet-save-btn")
|
||||
.addEventListener("click", async () => {
|
||||
const name = detailPanel
|
||||
.querySelector(".snippet-ed-name")
|
||||
.value.trim();
|
||||
const command = detailPanel.querySelector(".snippet-ed-cmd").value;
|
||||
const description = detailPanel
|
||||
.querySelector(".snippet-ed-desc")
|
||||
.value.trim();
|
||||
const folder = detailPanel
|
||||
.querySelector(".snippet-ed-folder")
|
||||
.value.trim();
|
||||
|
||||
if (!name) {
|
||||
this.app.notifications.error('Name is required');
|
||||
this.app.notifications.error("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
this.app.notifications.error('Command is required');
|
||||
this.app.notifications.error("Command is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
id: isNew ? '' : snippet.id,
|
||||
id: isNew ? "" : snippet.id,
|
||||
name,
|
||||
command,
|
||||
description,
|
||||
folder,
|
||||
tags: isNew ? [] : (snippet.tags || []),
|
||||
tags: isNew ? [] : snippet.tags || [],
|
||||
};
|
||||
|
||||
let saved;
|
||||
@@ -284,23 +428,27 @@ class SnippetLibrary {
|
||||
|
||||
this._renderList(modal, closeFn);
|
||||
this._showDetail(modal, saved, closeFn);
|
||||
this.app.notifications.info(isNew ? 'Snippet created' : 'Snippet updated');
|
||||
this.app.notifications.info(
|
||||
isNew ? "Snippet created" : "Snippet updated",
|
||||
);
|
||||
} catch (err) {
|
||||
this.app.notifications.error('Failed to save snippet: ' + err);
|
||||
this.app.notifications.error("Failed to save snippet: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
detailPanel.querySelector('.snippet-cancel-btn').addEventListener('click', () => {
|
||||
detailPanel
|
||||
.querySelector(".snippet-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
if (snippet) {
|
||||
this._showDetail(modal, snippet, closeFn);
|
||||
} else {
|
||||
detailPanel.innerHTML = '<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||
detailPanel.innerHTML =
|
||||
'<div class="snippet-detail-empty" style="display:flex;align-items:center;justify-content:center;flex:1;color:var(--text-tertiary);font-size:var(--text-sm);">Select a snippet or create a new one</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SnippetLibrary = SnippetLibrary;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — SSH Key Manager.
|
||||
* Panel for listing, generating, copying, and deleting SSH key pairs.
|
||||
*/
|
||||
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
|
||||
class SSHKeyManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this._backdrop = null;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
open() {
|
||||
if (this._backdrop) return;
|
||||
this._render();
|
||||
this._loadKeys();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.classList.remove("is-visible");
|
||||
const modal = this._backdrop.querySelector(".modal");
|
||||
if (modal) modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.remove();
|
||||
this._backdrop = null;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">SSH Key Manager</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sshkey-toolbar">
|
||||
<button class="btn btn-sm btn-accent sshkey-generate-btn">Generate New Key</button>
|
||||
</div>
|
||||
<div class="sshkey-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
this._backdrop = backdrop;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
// Events
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => this.close());
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) this.close();
|
||||
});
|
||||
modal
|
||||
.querySelector(".sshkey-generate-btn")
|
||||
.addEventListener("click", () => this._showGenerateForm());
|
||||
}
|
||||
|
||||
async _loadKeys() {
|
||||
const list = this._backdrop.querySelector(".sshkey-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = '<div class="sshkey-loading">Loading keys…</div>';
|
||||
|
||||
try {
|
||||
const keys = await window.go.api.Service.ListSSHKeyPairs();
|
||||
if (keys.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="sshkey-empty">No SSH keys found in ~/.ssh/</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const key of keys) {
|
||||
list.appendChild(this._renderKeyRow(key));
|
||||
}
|
||||
} catch (err) {
|
||||
list.innerHTML = `<div class="sshkey-error">Failed to load keys: ${err}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderKeyRow(key) {
|
||||
const row = createElement("div", "sshkey-row");
|
||||
|
||||
const info = createElement("div", "sshkey-info");
|
||||
const name = createElement("div", "sshkey-name", {
|
||||
textContent: key.name,
|
||||
});
|
||||
const meta = createElement("div", "sshkey-meta", {
|
||||
textContent: key.type.toUpperCase() + (key.bits ? ` ${key.bits}` : ""),
|
||||
});
|
||||
info.appendChild(name);
|
||||
info.appendChild(meta);
|
||||
|
||||
const actions = createElement("div", "sshkey-actions");
|
||||
|
||||
const copyBtn = createElement("button", "btn btn-xs", {
|
||||
textContent: "Copy Public Key",
|
||||
});
|
||||
copyBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
copyBtn.addEventListener("click", () => this._copyPublicKey(key));
|
||||
|
||||
const deleteBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "Delete",
|
||||
});
|
||||
deleteBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
deleteBtn.addEventListener("click", () => this._confirmDelete(key));
|
||||
|
||||
actions.appendChild(copyBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async _copyPublicKey(key) {
|
||||
try {
|
||||
const pubKey = await window.go.api.Service.GetSSHPublicKey(key.name);
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(pubKey);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(pubKey);
|
||||
}
|
||||
this.app.notifications.success("Public key copied to clipboard");
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Failed to copy: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
_confirmDelete(key) {
|
||||
const confirmed = this._showConfirm(
|
||||
"Delete Key",
|
||||
`Delete key pair "${key.name}"? This cannot be undone.`,
|
||||
"Delete",
|
||||
);
|
||||
confirmed.then((ok) => {
|
||||
if (ok) this._deleteKey(key);
|
||||
});
|
||||
}
|
||||
|
||||
async _deleteKey(key) {
|
||||
try {
|
||||
await window.go.api.Service.DeleteSSHKey(key.name);
|
||||
this.app.notifications.success(`Key "${key.name}" deleted`);
|
||||
this._loadKeys();
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Delete failed: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate form ────────────────────────────────────────────────
|
||||
|
||||
_showGenerateForm() {
|
||||
const list = this._backdrop.querySelector(".sshkey-list");
|
||||
if (!list) return;
|
||||
|
||||
const form = createElement("div", "sshkey-generate-form");
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Key Name</label>
|
||||
<input type="text" class="form-input sshkey-name-input" placeholder="id_ed25519_nexus" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-input sshkey-type-select">
|
||||
<option value="ed25519" selected>Ed25519 (recommended)</option>
|
||||
<option value="rsa">RSA</option>
|
||||
<option value="ecdsa">ECDSA</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sshkey-bits-group" style="display:none">
|
||||
<label class="form-label">Bits</label>
|
||||
<select class="form-input sshkey-bits-select">
|
||||
<option value="4096" selected>4096</option>
|
||||
<option value="2048">2048</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passphrase (optional)</label>
|
||||
<input type="password" class="form-input sshkey-pass-input" placeholder="Leave blank for no passphrase" autocomplete="off" />
|
||||
</div>
|
||||
<div class="sshkey-form-actions">
|
||||
<button class="btn btn-sm sshkey-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent sshkey-create-btn">Generate</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert form above the key list
|
||||
list.parentNode.insertBefore(form, list);
|
||||
list.style.display = "none";
|
||||
|
||||
// Show bits field for RSA
|
||||
const typeSelect = form.querySelector(".sshkey-type-select");
|
||||
const bitsGroup = form.querySelector(".sshkey-bits-group");
|
||||
typeSelect.addEventListener("change", () => {
|
||||
bitsGroup.style.display = typeSelect.value === "rsa" ? "" : "none";
|
||||
});
|
||||
|
||||
form.querySelector(".sshkey-cancel-btn").addEventListener("click", () => {
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
});
|
||||
|
||||
form
|
||||
.querySelector(".sshkey-create-btn")
|
||||
.addEventListener("click", async () => {
|
||||
const nameInput = form.querySelector(".sshkey-name-input");
|
||||
const name = nameInput.value.trim();
|
||||
const keyType = typeSelect.value;
|
||||
const bits = parseInt(
|
||||
form.querySelector(".sshkey-bits-select").value,
|
||||
10,
|
||||
);
|
||||
const passphrase = form.querySelector(".sshkey-pass-input").value;
|
||||
|
||||
if (!name) {
|
||||
this.app.notifications.error("Key name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.go.api.Service.GenerateSSHKey(
|
||||
name,
|
||||
keyType,
|
||||
bits,
|
||||
passphrase,
|
||||
);
|
||||
this.app.notifications.success(
|
||||
`Key "${name}" generated successfully`,
|
||||
);
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
this._loadKeys();
|
||||
} catch (err) {
|
||||
this.app.notifications.error("Generation failed: " + err);
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelector(".sshkey-name-input").focus();
|
||||
}
|
||||
|
||||
// ── Confirmation helper ──────────────────────────────────────────
|
||||
|
||||
_showConfirm(title, message, confirmLabel) {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-sm);color:var(--text-secondary)">${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||
<button class="btn btn-sm btn-danger confirm-ok">${confirmLabel}</button>
|
||||
</div>
|
||||
`;
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = (val) => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
resolve(val);
|
||||
};
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.SSHKeyManager = SSHKeyManager;
|
||||
})();
|
||||
+28
-25
@@ -1,11 +1,11 @@
|
||||
(function() {
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — application state management.
|
||||
* Provides a centralised store for profiles, sessions, and preferences,
|
||||
* emitting change events so the UI stays in sync.
|
||||
*/
|
||||
|
||||
class State {
|
||||
class State {
|
||||
constructor() {
|
||||
/** @type {Array<Object>} */
|
||||
this.profiles = [];
|
||||
@@ -28,6 +28,9 @@ class State {
|
||||
/** @type {boolean} Whether broadcast mode is active (keystrokes to all sessions). */
|
||||
this.broadcastMode = false;
|
||||
|
||||
/** @type {Set<string>|null} Session IDs to broadcast to. Null means all. */
|
||||
this.broadcastTargets = null;
|
||||
|
||||
/** @type {Map<string, Set<Function>>} event name → callbacks */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
@@ -84,7 +87,7 @@ class State {
|
||||
*/
|
||||
setProfiles(profiles) {
|
||||
this.profiles = profiles;
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,8 +96,8 @@ class State {
|
||||
*/
|
||||
addProfile(profile) {
|
||||
this.profiles.push(profile);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:added', profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:added", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,8 +109,8 @@ class State {
|
||||
if (idx !== -1) {
|
||||
this.profiles[idx] = profile;
|
||||
}
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:updated', profile);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:updated", profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +119,8 @@ class State {
|
||||
*/
|
||||
removeProfile(id) {
|
||||
this.profiles = this.profiles.filter((p) => p.id !== id);
|
||||
this.emit('profiles:changed', this.profiles);
|
||||
this.emit('profile:removed', id);
|
||||
this.emit("profiles:changed", this.profiles);
|
||||
this.emit("profile:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,8 +140,8 @@ class State {
|
||||
*/
|
||||
addSession(sessionInfo) {
|
||||
this.sessions.set(sessionInfo.id, sessionInfo);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:added', sessionInfo);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:added", sessionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,8 +153,8 @@ class State {
|
||||
const existing = this.sessions.get(id);
|
||||
if (existing) {
|
||||
Object.assign(existing, updates);
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:updated', existing);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:updated", existing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +167,11 @@ class State {
|
||||
if (this.activeSessionId === id) {
|
||||
// Activate the most recent remaining session, or null
|
||||
const remaining = [...this.sessions.keys()];
|
||||
this.activeSessionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
||||
this.activeSessionId =
|
||||
remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
||||
}
|
||||
this.emit('sessions:changed', this.sessions);
|
||||
this.emit('session:removed', id);
|
||||
this.emit("sessions:changed", this.sessions);
|
||||
this.emit("session:removed", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +189,7 @@ class State {
|
||||
*/
|
||||
setActiveSession(id) {
|
||||
this.activeSessionId = id;
|
||||
this.emit('session:activated', id);
|
||||
this.emit("session:activated", id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +208,7 @@ class State {
|
||||
*/
|
||||
setPreferences(prefs) {
|
||||
this.preferences = prefs;
|
||||
this.emit('preferences:changed', this.preferences);
|
||||
this.emit("preferences:changed", this.preferences);
|
||||
}
|
||||
|
||||
// ── Secret store ─────────────────────────────────────────────────
|
||||
@@ -215,7 +219,7 @@ class State {
|
||||
*/
|
||||
setSecretStoreState(unlocked) {
|
||||
this.secretStoreUnlocked = unlocked;
|
||||
this.emit('secretStore:changed', unlocked);
|
||||
this.emit("secretStore:changed", unlocked);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,11 +228,10 @@ class State {
|
||||
*/
|
||||
setSecretStoreInitialised(initialised) {
|
||||
this.secretStoreInitialised = initialised;
|
||||
this.emit('secretStore:init', initialised);
|
||||
this.emit("secretStore:init", initialised);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.State = State;
|
||||
})();
|
||||
|
||||
+34
-3
@@ -100,9 +100,12 @@
|
||||
setActive(sessionId) {
|
||||
this.app.state.setActiveSession(sessionId);
|
||||
|
||||
// Highlight active tab
|
||||
// Highlight active tab and clear activity indicator
|
||||
this.tabList.querySelectorAll(".tab").forEach((t) => {
|
||||
t.classList.toggle("tab-active", t.dataset.sessionId === sessionId);
|
||||
if (t.dataset.sessionId === sessionId) {
|
||||
t.classList.remove("tab-has-activity");
|
||||
}
|
||||
});
|
||||
|
||||
// Show corresponding panel, hide others
|
||||
@@ -116,11 +119,18 @@
|
||||
this.app.sidebar.highlightActive(session.profileId);
|
||||
}
|
||||
|
||||
// Fit the terminal for the newly active session
|
||||
this.app.terminals.fitActive();
|
||||
// Fit and refresh the terminal for the newly active session.
|
||||
// Double-RAF ensures the panel has layout dimensions before fitting.
|
||||
requestAnimationFrame(() => {
|
||||
this.app.terminals.fitSession(sessionId);
|
||||
const entry = this.app.terminals.terminals.get(sessionId);
|
||||
if (entry) {
|
||||
entry.term.refresh(0, entry.term.rows - 1);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.app.terminals.focusTerminal(sessionId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,6 +428,21 @@
|
||||
return [...this.order];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a background tab as having new activity.
|
||||
* Does nothing if the session is currently active.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
markActivity(sessionId) {
|
||||
if (sessionId === this.app.state.activeSessionId) return;
|
||||
const tabEl = this.tabList.querySelector(
|
||||
`[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl && !tabEl.classList.contains("tab-has-activity")) {
|
||||
tabEl.classList.add("tab-has-activity");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID of the next tab (wrapping round).
|
||||
* @returns {string|null}
|
||||
@@ -455,6 +480,8 @@
|
||||
const protoLabel =
|
||||
sessionInfo.protocol === "github"
|
||||
? "GitHub"
|
||||
: sessionInfo.protocol === "local"
|
||||
? "Local"
|
||||
: sessionInfo.protocol.toUpperCase();
|
||||
const badge = createElement(
|
||||
"span",
|
||||
@@ -481,6 +508,10 @@
|
||||
);
|
||||
tab.appendChild(status);
|
||||
|
||||
// Activity indicator (hidden by default, shown when background tab has output)
|
||||
const activity = createElement("span", "tab-activity");
|
||||
tab.appendChild(activity);
|
||||
|
||||
// Pin indicator
|
||||
if (sessionInfo.pinned) {
|
||||
tab.classList.add("tab-pinned");
|
||||
|
||||
+249
-2
@@ -47,6 +47,8 @@
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
scrollOnUserInput: true,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
|
||||
// Load addons
|
||||
@@ -150,10 +152,13 @@
|
||||
const b64 = btoa(data);
|
||||
window.go.api.Service.WriteSession(sessionId, b64);
|
||||
|
||||
// Broadcast to all other connected sessions if enabled
|
||||
// Broadcast to other connected sessions if enabled
|
||||
if (this.app.state.broadcastMode) {
|
||||
const targets = this.app.state.broadcastTargets;
|
||||
for (const [otherId] of this.terminals) {
|
||||
if (otherId === sessionId) continue;
|
||||
// If targets are set, only broadcast to selected sessions
|
||||
if (targets && !targets.has(otherId)) continue;
|
||||
const otherSession = this.app.state.getSession(otherId);
|
||||
if (otherSession && otherSession.state === "connected") {
|
||||
try {
|
||||
@@ -504,6 +509,8 @@
|
||||
const { createElement, dismissContextMenus } = window.NexusTerminal;
|
||||
dismissContextMenus();
|
||||
|
||||
const isMac = navigator.platform.indexOf("Mac") !== -1;
|
||||
const modKey = isMac ? "⌘" : "Ctrl+";
|
||||
const hasSelection = term.hasSelection();
|
||||
const menu = createElement("div", "context-menu");
|
||||
menu.style.left = `${x}px`;
|
||||
@@ -512,6 +519,7 @@
|
||||
const items = [
|
||||
{
|
||||
label: "Copy",
|
||||
shortcut: modKey + "C",
|
||||
disabled: !hasSelection,
|
||||
action: () => {
|
||||
const text = term.getSelection();
|
||||
@@ -527,6 +535,7 @@
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
shortcut: modKey + "V",
|
||||
action: () => {
|
||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||
window.runtime
|
||||
@@ -548,19 +557,41 @@
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Select All",
|
||||
shortcut: modKey + "A",
|
||||
action: () => term.selectAll(),
|
||||
},
|
||||
{
|
||||
label: "Find…",
|
||||
shortcut: modKey + "F",
|
||||
action: () => this.app._openTerminalSearch(),
|
||||
},
|
||||
{
|
||||
label: "Send Snippet…",
|
||||
action: () => this.app.snippets.open(),
|
||||
},
|
||||
{
|
||||
label: "Port Forwards…",
|
||||
action: () => this._showPortForwardPanel(sessionId),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Split Right",
|
||||
shortcut: modKey + "⇧D",
|
||||
action: () => this.app.splitPane("vertical"),
|
||||
},
|
||||
{
|
||||
label: "Split Down",
|
||||
shortcut: modKey + "⇧E",
|
||||
action: () => this.app.splitPane("horizontal"),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
label: "Export Output",
|
||||
action: () => this._exportTerminalOutput(sessionId, term),
|
||||
},
|
||||
{
|
||||
label: "Clear Terminal",
|
||||
shortcut: modKey + "K",
|
||||
action: () => term.clear(),
|
||||
},
|
||||
{
|
||||
@@ -574,9 +605,17 @@
|
||||
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||
continue;
|
||||
}
|
||||
const btn = createElement("button", "context-menu-item", {
|
||||
const btn = createElement("button", "context-menu-item");
|
||||
const labelSpan = createElement("span", "context-menu-label", {
|
||||
textContent: entry.label,
|
||||
});
|
||||
btn.appendChild(labelSpan);
|
||||
if (entry.shortcut) {
|
||||
const shortcutSpan = createElement("span", "context-menu-shortcut", {
|
||||
textContent: entry.shortcut,
|
||||
});
|
||||
btn.appendChild(shortcutSpan);
|
||||
}
|
||||
if (entry.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("context-menu-disabled");
|
||||
@@ -1169,6 +1208,214 @@
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the terminal scrollback buffer as plain text.
|
||||
* @param {string} sessionId
|
||||
* @param {Terminal} term
|
||||
* @private
|
||||
*/
|
||||
_exportTerminalOutput(sessionId, term) {
|
||||
const { createElement } = window.NexusTerminal;
|
||||
const buffer = term.buffer.active;
|
||||
const lines = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) lines.push(line.translateToString(true));
|
||||
}
|
||||
const text = lines.join("\n").trimEnd();
|
||||
|
||||
// Use Wails save dialog if available
|
||||
if (window.runtime && window.runtime.SaveFileDialog) {
|
||||
// Copy to clipboard as a fallback
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||
window.runtime.ClipboardSetText(text);
|
||||
} else {
|
||||
navigator.clipboard.writeText(text).catch(function () {});
|
||||
}
|
||||
|
||||
// Also offer download via blob
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "terminal-output-" + sessionId.substring(0, 8) + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.app.notifications.success(
|
||||
"Terminal output exported & copied to clipboard",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the port forwarding management panel for an SSH session.
|
||||
* @param {string} sessionId
|
||||
* @private
|
||||
*/
|
||||
_showPortForwardPanel(sessionId) {
|
||||
const { createElement, escapeHtml } = window.NexusTerminal;
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
if (!session || session.protocol !== "ssh") {
|
||||
this.app.notifications.error(
|
||||
"Port forwarding is only available for SSH sessions",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Port Forwards</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="min-height:200px;">
|
||||
<div class="portfwd-list" style="margin-bottom:var(--space-3);"></div>
|
||||
<div class="portfwd-add" style="border-top:1px solid var(--border-subtle);padding-top:var(--space-3);">
|
||||
<div style="font-weight:var(--weight-medium);font-size:var(--text-sm);margin-bottom:var(--space-2);">Add New Forward</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr 60px 20px 1fr 60px;gap:var(--space-2);align-items:center;">
|
||||
<select class="form-input pf-direction" style="height:28px;font-size:var(--text-xs);">
|
||||
<option value="local">Local →</option>
|
||||
<option value="remote">Remote ←</option>
|
||||
</select>
|
||||
<input class="form-input pf-bind-host" type="text" placeholder="127.0.0.1" value="127.0.0.1" style="height:28px;font-size:var(--text-xs);" />
|
||||
<input class="form-input pf-bind-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
|
||||
<span style="text-align:center;color:var(--text-tertiary);">→</span>
|
||||
<input class="form-input pf-dest-host" type="text" placeholder="localhost" value="localhost" style="height:28px;font-size:var(--text-xs);" />
|
||||
<input class="form-input pf-dest-port" type="number" placeholder="Port" style="height:28px;font-size:var(--text-xs);" />
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);">
|
||||
<input class="form-input pf-label" type="text" placeholder="Label (optional)" style="flex:1;height:28px;font-size:var(--text-xs);" />
|
||||
<button class="btn btn-sm btn-primary pf-add-btn">Start Forward</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
modal.querySelector(".modal-close").addEventListener("click", close);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
|
||||
const listEl = modal.querySelector(".portfwd-list");
|
||||
const self = this;
|
||||
|
||||
const renderForwards = async () => {
|
||||
try {
|
||||
const forwards =
|
||||
(await window.go.api.Service.ListPortForwards(sessionId)) || [];
|
||||
listEl.innerHTML = "";
|
||||
if (forwards.length === 0) {
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--space-2);">No active port forwards</div>';
|
||||
return;
|
||||
}
|
||||
for (const fwd of forwards) {
|
||||
const row = createElement("div", "", {});
|
||||
row.style.cssText =
|
||||
"display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) 0;border-bottom:1px solid var(--border-subtle);";
|
||||
const dirLabel = fwd.direction === "local" ? "L" : "R";
|
||||
const dirColor =
|
||||
fwd.direction === "local" ? "var(--accent)" : "var(--warning)";
|
||||
row.innerHTML = `
|
||||
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:${dirColor};width:16px;">${dirLabel}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-primary);flex:1;">
|
||||
${escapeHtml(fwd.bindHost || "127.0.0.1")}:${fwd.bindPort} → ${escapeHtml(fwd.destHost || "localhost")}:${fwd.destPort}
|
||||
</span>
|
||||
${fwd.label ? `<span style="font-size:var(--text-xs);color:var(--text-tertiary);">${escapeHtml(fwd.label)}</span>` : ""}
|
||||
`;
|
||||
const stopBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "Stop",
|
||||
});
|
||||
stopBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await window.go.api.Service.StopPortForward(sessionId, fwd.id);
|
||||
self.app.notifications.info("Forward stopped");
|
||||
renderForwards();
|
||||
} catch (err) {
|
||||
self.app.notifications.error(
|
||||
"Failed to stop: " + (err.message || err),
|
||||
);
|
||||
}
|
||||
});
|
||||
row.appendChild(stopBtn);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
} catch (err) {
|
||||
listEl.innerHTML =
|
||||
'<div style="color:var(--error);font-size:var(--text-sm);">Failed to load forwards</div>';
|
||||
}
|
||||
};
|
||||
|
||||
renderForwards();
|
||||
|
||||
// Add forward button
|
||||
modal.querySelector(".pf-add-btn").addEventListener("click", async () => {
|
||||
const direction = modal.querySelector(".pf-direction").value;
|
||||
const bindHost =
|
||||
modal.querySelector(".pf-bind-host").value.trim() || "127.0.0.1";
|
||||
const bindPort = parseInt(
|
||||
modal.querySelector(".pf-bind-port").value,
|
||||
10,
|
||||
);
|
||||
const destHost =
|
||||
modal.querySelector(".pf-dest-host").value.trim() || "localhost";
|
||||
const destPort = parseInt(
|
||||
modal.querySelector(".pf-dest-port").value,
|
||||
10,
|
||||
);
|
||||
const label = modal.querySelector(".pf-label").value.trim();
|
||||
|
||||
if (!bindPort || !destPort) {
|
||||
self.app.notifications.error(
|
||||
"Bind port and destination port are required",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.go.api.Service.StartPortForward(sessionId, {
|
||||
id: "",
|
||||
direction: direction,
|
||||
bindHost: bindHost,
|
||||
bindPort: bindPort,
|
||||
destHost: destHost,
|
||||
destPort: destPort,
|
||||
enabled: true,
|
||||
label: label,
|
||||
});
|
||||
self.app.notifications.success("Forward started");
|
||||
modal.querySelector(".pf-bind-port").value = "";
|
||||
modal.querySelector(".pf-dest-port").value = "";
|
||||
modal.querySelector(".pf-label").value = "";
|
||||
renderForwards();
|
||||
} catch (err) {
|
||||
self.app.notifications.error(
|
||||
"Failed to start forward: " + (err.message || err),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the xterm.js colour theme.
|
||||
* @returns {Object}
|
||||
|
||||
+287
-204
@@ -1,264 +1,347 @@
|
||||
(function() {
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — terminal colour themes.
|
||||
* Provides a collection of preset xterm.js colour schemes and a manager
|
||||
* for applying them to terminal instances.
|
||||
*/
|
||||
|
||||
/**
|
||||
/**
|
||||
* Preset terminal colour themes.
|
||||
* Each entry contains the full xterm.js theme object.
|
||||
*/
|
||||
const THEMES = {
|
||||
const THEMES = {
|
||||
charcoal: {
|
||||
label: 'Charcoal (Default)',
|
||||
label: "Charcoal (Default)",
|
||||
theme: {
|
||||
background: '#1c1c1c',
|
||||
foreground: '#e4e4e4',
|
||||
cursor: '#4ecdc4',
|
||||
cursorAccent: '#1c1c1c',
|
||||
selectionBackground: 'rgba(78, 205, 196, 0.3)',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#1c1c1c',
|
||||
red: '#ef5350',
|
||||
green: '#66bb6a',
|
||||
yellow: '#ffa726',
|
||||
blue: '#42a5f5',
|
||||
magenta: '#ab47bc',
|
||||
cyan: '#4ecdc4',
|
||||
white: '#e4e4e4',
|
||||
brightBlack: '#555555',
|
||||
brightRed: '#ff7043',
|
||||
brightGreen: '#81c784',
|
||||
brightYellow: '#ffcc02',
|
||||
brightBlue: '#64b5f6',
|
||||
brightMagenta: '#ce93d8',
|
||||
brightCyan: '#80deea',
|
||||
brightWhite: '#ffffff',
|
||||
background: "#1c1c1c",
|
||||
foreground: "#e4e4e4",
|
||||
cursor: "#4ecdc4",
|
||||
cursorAccent: "#1c1c1c",
|
||||
selectionBackground: "rgba(78, 205, 196, 0.3)",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#1c1c1c",
|
||||
red: "#ef5350",
|
||||
green: "#66bb6a",
|
||||
yellow: "#ffa726",
|
||||
blue: "#42a5f5",
|
||||
magenta: "#ab47bc",
|
||||
cyan: "#4ecdc4",
|
||||
white: "#e4e4e4",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#ff7043",
|
||||
brightGreen: "#81c784",
|
||||
brightYellow: "#ffcc02",
|
||||
brightBlue: "#64b5f6",
|
||||
brightMagenta: "#ce93d8",
|
||||
brightCyan: "#80deea",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
'solarized-dark': {
|
||||
label: 'Solarized Dark',
|
||||
"solarized-dark": {
|
||||
label: "Solarized Dark",
|
||||
theme: {
|
||||
background: '#002b36',
|
||||
foreground: '#839496',
|
||||
cursor: '#93a1a1',
|
||||
cursorAccent: '#002b36',
|
||||
selectionBackground: 'rgba(147, 161, 161, 0.3)',
|
||||
selectionForeground: '#fdf6e3',
|
||||
black: '#073642',
|
||||
red: '#dc322f',
|
||||
green: '#859900',
|
||||
yellow: '#b58900',
|
||||
blue: '#268bd2',
|
||||
magenta: '#d33682',
|
||||
cyan: '#2aa198',
|
||||
white: '#eee8d5',
|
||||
brightBlack: '#586e75',
|
||||
brightRed: '#cb4b16',
|
||||
brightGreen: '#586e75',
|
||||
brightYellow: '#657b83',
|
||||
brightBlue: '#839496',
|
||||
brightMagenta: '#6c71c4',
|
||||
brightCyan: '#93a1a1',
|
||||
brightWhite: '#fdf6e3',
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#93a1a1",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "rgba(147, 161, 161, 0.3)",
|
||||
selectionForeground: "#fdf6e3",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#586e75",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
label: 'Monokai',
|
||||
label: "Monokai",
|
||||
theme: {
|
||||
background: '#272822',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f0',
|
||||
cursorAccent: '#272822',
|
||||
selectionBackground: 'rgba(73, 72, 62, 0.6)',
|
||||
selectionForeground: '#f8f8f2',
|
||||
black: '#272822',
|
||||
red: '#f92672',
|
||||
green: '#a6e22e',
|
||||
yellow: '#f4bf75',
|
||||
blue: '#66d9ef',
|
||||
magenta: '#ae81ff',
|
||||
cyan: '#a1efe4',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#75715e',
|
||||
brightRed: '#f92672',
|
||||
brightGreen: '#a6e22e',
|
||||
brightYellow: '#f4bf75',
|
||||
brightBlue: '#66d9ef',
|
||||
brightMagenta: '#ae81ff',
|
||||
brightCyan: '#a1efe4',
|
||||
brightWhite: '#f9f8f5',
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "rgba(73, 72, 62, 0.6)",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
},
|
||||
|
||||
nord: {
|
||||
label: 'Nord',
|
||||
label: "Nord",
|
||||
theme: {
|
||||
background: '#2e3440',
|
||||
foreground: '#d8dee9',
|
||||
cursor: '#d8dee9',
|
||||
cursorAccent: '#2e3440',
|
||||
selectionBackground: 'rgba(136, 192, 208, 0.3)',
|
||||
selectionForeground: '#eceff4',
|
||||
black: '#3b4252',
|
||||
red: '#bf616a',
|
||||
green: '#a3be8c',
|
||||
yellow: '#ebcb8b',
|
||||
blue: '#81a1c1',
|
||||
magenta: '#b48ead',
|
||||
cyan: '#88c0d0',
|
||||
white: '#e5e9f0',
|
||||
brightBlack: '#4c566a',
|
||||
brightRed: '#bf616a',
|
||||
brightGreen: '#a3be8c',
|
||||
brightYellow: '#ebcb8b',
|
||||
brightBlue: '#81a1c1',
|
||||
brightMagenta: '#b48ead',
|
||||
brightCyan: '#8fbcbb',
|
||||
brightWhite: '#eceff4',
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "rgba(136, 192, 208, 0.3)",
|
||||
selectionForeground: "#eceff4",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
label: 'Dracula',
|
||||
label: "Dracula",
|
||||
theme: {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: 'rgba(68, 71, 90, 0.6)',
|
||||
selectionForeground: '#f8f8f2',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "rgba(68, 71, 90, 0.6)",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
'one-dark': {
|
||||
label: 'One Dark',
|
||||
"one-dark": {
|
||||
label: "One Dark",
|
||||
theme: {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: 'rgba(82, 139, 255, 0.3)',
|
||||
selectionForeground: '#abb2bf',
|
||||
black: '#282c34',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#e5c07b',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#e06c75',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "rgba(82, 139, 255, 0.3)",
|
||||
selectionForeground: "#abb2bf",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
'gruvbox-dark': {
|
||||
label: 'Gruvbox Dark',
|
||||
"gruvbox-dark": {
|
||||
label: "Gruvbox Dark",
|
||||
theme: {
|
||||
background: '#282828',
|
||||
foreground: '#ebdbb2',
|
||||
cursor: '#ebdbb2',
|
||||
cursorAccent: '#282828',
|
||||
selectionBackground: 'rgba(214, 93, 14, 0.3)',
|
||||
selectionForeground: '#ebdbb2',
|
||||
black: '#282828',
|
||||
red: '#cc241d',
|
||||
green: '#98971a',
|
||||
yellow: '#d79921',
|
||||
blue: '#458588',
|
||||
magenta: '#b16286',
|
||||
cyan: '#689d6a',
|
||||
white: '#a89984',
|
||||
brightBlack: '#928374',
|
||||
brightRed: '#fb4934',
|
||||
brightGreen: '#b8bb26',
|
||||
brightYellow: '#fabd2f',
|
||||
brightBlue: '#83a598',
|
||||
brightMagenta: '#d3869b',
|
||||
brightCyan: '#8ec07c',
|
||||
brightWhite: '#ebdbb2',
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "rgba(214, 93, 14, 0.3)",
|
||||
selectionForeground: "#ebdbb2",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
|
||||
'tokyo-night': {
|
||||
label: 'Tokyo Night',
|
||||
"tokyo-night": {
|
||||
label: "Tokyo Night",
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: 'rgba(41, 46, 66, 0.9)',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "rgba(41, 46, 66, 0.9)",
|
||||
selectionForeground: "#c0caf5",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
"catppuccin-mocha": {
|
||||
label: "Catppuccin Mocha",
|
||||
theme: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "rgba(88, 91, 112, 0.5)",
|
||||
selectionForeground: "#cdd6f4",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
"rose-pine": {
|
||||
label: "Rosé Pine",
|
||||
theme: {
|
||||
background: "#191724",
|
||||
foreground: "#e0def4",
|
||||
cursor: "#524f67",
|
||||
cursorAccent: "#e0def4",
|
||||
selectionBackground: "rgba(110, 106, 134, 0.4)",
|
||||
selectionForeground: "#e0def4",
|
||||
black: "#26233a",
|
||||
red: "#eb6f92",
|
||||
green: "#31748f",
|
||||
yellow: "#f6c177",
|
||||
blue: "#9ccfd8",
|
||||
magenta: "#c4a7e7",
|
||||
cyan: "#ebbcba",
|
||||
white: "#e0def4",
|
||||
brightBlack: "#6e6a86",
|
||||
brightRed: "#eb6f92",
|
||||
brightGreen: "#31748f",
|
||||
brightYellow: "#f6c177",
|
||||
brightBlue: "#9ccfd8",
|
||||
brightMagenta: "#c4a7e7",
|
||||
brightCyan: "#ebbcba",
|
||||
brightWhite: "#e0def4",
|
||||
},
|
||||
},
|
||||
|
||||
"solarized-light": {
|
||||
label: "Solarized Light",
|
||||
theme: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#586e75",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "rgba(7, 54, 66, 0.2)",
|
||||
selectionForeground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the theme object for the given theme name.
|
||||
* Falls back to 'charcoal' if not found.
|
||||
* @param {string} name
|
||||
* @returns {Object} xterm.js theme object
|
||||
*/
|
||||
function getTerminalTheme(name) {
|
||||
const entry = THEMES[name || 'charcoal'];
|
||||
function getTerminalTheme(name) {
|
||||
const entry = THEMES[name || "charcoal"];
|
||||
return entry ? entry.theme : THEMES.charcoal.theme;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Returns all theme names and labels for building a selector.
|
||||
* @returns {Array<{value: string, label: string}>}
|
||||
*/
|
||||
function listTerminalThemes() {
|
||||
function listTerminalThemes() {
|
||||
return Object.entries(THEMES).map(([value, entry]) => ({
|
||||
value,
|
||||
label: entry.label,
|
||||
}));
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
|
||||
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.getTerminalTheme = getTerminalTheme;
|
||||
window.NexusTerminal.listTerminalThemes = listTerminalThemes;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — Trigger Rules Engine.
|
||||
* Monitors terminal output and fires actions (highlight, notify, sound)
|
||||
* when patterns match. Rules are stored in localStorage.
|
||||
*/
|
||||
|
||||
const { createElement } = window.NexusTerminal;
|
||||
|
||||
const STORAGE_KEY = "nexus:trigger-rules";
|
||||
|
||||
class TriggerEngine {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this._rules = this._load();
|
||||
this._backdrop = null;
|
||||
// Buffer per session to accumulate partial lines
|
||||
this._buffers = new Map();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks incoming terminal data against all enabled rules.
|
||||
* Called from the session:data event handler.
|
||||
* @param {string} sessionId
|
||||
* @param {string} text - decoded terminal text
|
||||
*/
|
||||
check(sessionId, text) {
|
||||
if (this._rules.length === 0) return;
|
||||
|
||||
// Accumulate text and check line by line
|
||||
const buf = (this._buffers.get(sessionId) || "") + text;
|
||||
const lines = buf.split("\n");
|
||||
// Keep the last incomplete line in the buffer
|
||||
this._buffers.set(sessionId, lines.pop());
|
||||
|
||||
for (const line of lines) {
|
||||
this._checkLine(sessionId, line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the trigger rules management panel.
|
||||
*/
|
||||
open() {
|
||||
if (this._backdrop) return;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the trigger rules panel.
|
||||
*/
|
||||
close() {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.classList.remove("is-visible");
|
||||
const modal = this._backdrop.querySelector(".modal");
|
||||
if (modal) modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
if (this._backdrop) {
|
||||
this._backdrop.remove();
|
||||
this._backdrop = null;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up session buffer on disconnect.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
cleanup(sessionId) {
|
||||
this._buffers.delete(sessionId);
|
||||
}
|
||||
|
||||
// ── Rule matching ────────────────────────────────────────────────
|
||||
|
||||
_checkLine(sessionId, line) {
|
||||
// Strip ANSI escape sequences for matching
|
||||
const clean = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
||||
|
||||
for (const rule of this._rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
let matched = false;
|
||||
if (rule.isRegex) {
|
||||
try {
|
||||
const re = new RegExp(rule.pattern, rule.caseSensitive ? "" : "i");
|
||||
matched = re.test(clean);
|
||||
} catch (_) {
|
||||
// Invalid regex — skip
|
||||
}
|
||||
} else {
|
||||
const haystack = rule.caseSensitive ? clean : clean.toLowerCase();
|
||||
const needle = rule.caseSensitive
|
||||
? rule.pattern
|
||||
: rule.pattern.toLowerCase();
|
||||
matched = haystack.includes(needle);
|
||||
}
|
||||
|
||||
if (!matched) continue;
|
||||
|
||||
// Fire actions
|
||||
if (rule.action === "notify" || rule.action === "both") {
|
||||
const session = this.app.state.getSession(sessionId);
|
||||
const label = session
|
||||
? session.tabName || session.profileName
|
||||
: sessionId;
|
||||
this.app.notifications.info(
|
||||
`[${label}] Trigger: ${rule.name || rule.pattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rule.action === "highlight" || rule.action === "both") {
|
||||
// Flash the tab briefly
|
||||
const tabEl = document.querySelector(
|
||||
`.tab[data-session-id="${sessionId}"]`,
|
||||
);
|
||||
if (tabEl) {
|
||||
tabEl.classList.add("tab-trigger-flash");
|
||||
setTimeout(() => tabEl.classList.remove("tab-trigger-flash"), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.action === "sound") {
|
||||
this._playBeep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_playBeep() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.value = 0.1;
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.15);
|
||||
} catch (_) {
|
||||
// Audio not available — silent
|
||||
}
|
||||
}
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
_save() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._rules));
|
||||
} catch (_) {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ───────────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-lg");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Trigger Rules</h3>
|
||||
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="font-size:var(--text-xs);color:var(--text-tertiary);margin-bottom:var(--space-3)">
|
||||
Rules match against terminal output and fire actions when a pattern is detected.
|
||||
</p>
|
||||
<div class="trigger-toolbar">
|
||||
<button class="btn btn-sm btn-accent trigger-add-btn">Add Rule</button>
|
||||
</div>
|
||||
<div class="trigger-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(modal);
|
||||
document.body.appendChild(backdrop);
|
||||
this._backdrop = backdrop;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
modal
|
||||
.querySelector(".modal-close")
|
||||
.addEventListener("click", () => this.close());
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) this.close();
|
||||
});
|
||||
modal
|
||||
.querySelector(".trigger-add-btn")
|
||||
.addEventListener("click", () => this._addRule());
|
||||
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
const list = this._backdrop.querySelector(".trigger-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
if (this._rules.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="trigger-empty">No trigger rules defined</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this._rules.length; i++) {
|
||||
list.appendChild(this._renderRuleRow(this._rules[i], i));
|
||||
}
|
||||
}
|
||||
|
||||
_renderRuleRow(rule, index) {
|
||||
const row = createElement("div", "trigger-row");
|
||||
|
||||
const toggle = createElement("input", "trigger-toggle");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = rule.enabled;
|
||||
toggle.addEventListener("change", () => {
|
||||
this._rules[index].enabled = toggle.checked;
|
||||
this._save();
|
||||
});
|
||||
|
||||
const info = createElement("div", "trigger-info");
|
||||
const name = createElement("div", "trigger-name", {
|
||||
textContent: rule.name || rule.pattern,
|
||||
});
|
||||
const meta = createElement("div", "trigger-meta", {
|
||||
textContent: `${rule.isRegex ? "Regex" : "Text"} · ${rule.action} · ${rule.caseSensitive ? "Case-sensitive" : "Case-insensitive"}`,
|
||||
});
|
||||
info.appendChild(name);
|
||||
info.appendChild(meta);
|
||||
|
||||
const pattern = createElement("code", "trigger-pattern", {
|
||||
textContent: rule.pattern,
|
||||
});
|
||||
|
||||
const actions = createElement("div", "trigger-actions");
|
||||
const editBtn = createElement("button", "btn btn-xs", {
|
||||
textContent: "Edit",
|
||||
});
|
||||
editBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
editBtn.addEventListener("click", () => this._editRule(index));
|
||||
|
||||
const delBtn = createElement("button", "btn btn-xs btn-danger", {
|
||||
textContent: "✕",
|
||||
});
|
||||
delBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
delBtn.addEventListener("click", () => {
|
||||
this._rules.splice(index, 1);
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(info);
|
||||
row.appendChild(pattern);
|
||||
row.appendChild(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
_addRule() {
|
||||
this._showRuleForm(null, (rule) => {
|
||||
this._rules.push(rule);
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
}
|
||||
|
||||
_editRule(index) {
|
||||
this._showRuleForm(this._rules[index], (rule) => {
|
||||
this._rules[index] = rule;
|
||||
this._save();
|
||||
this._renderList();
|
||||
});
|
||||
}
|
||||
|
||||
_showRuleForm(existing, onSave) {
|
||||
const list = this._backdrop.querySelector(".trigger-list");
|
||||
const toolbar = this._backdrop.querySelector(".trigger-toolbar");
|
||||
if (!list) return;
|
||||
|
||||
// Hide list and toolbar
|
||||
list.style.display = "none";
|
||||
if (toolbar) toolbar.style.display = "none";
|
||||
|
||||
const form = createElement("div", "trigger-form");
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name (optional)</label>
|
||||
<input type="text" class="form-input trigger-name-input" placeholder="e.g. Error alert" value="${existing ? existing.name || "" : ""}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pattern</label>
|
||||
<input type="text" class="form-input trigger-pattern-input" placeholder="e.g. ERROR|FATAL|panic" value="${existing ? existing.pattern : ""}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Match Type</label>
|
||||
<select class="form-input trigger-type-select">
|
||||
<option value="text"${existing && !existing.isRegex ? " selected" : ""}>Plain text</option>
|
||||
<option value="regex"${existing && existing.isRegex ? " selected" : ""}>Regex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Action</label>
|
||||
<select class="form-input trigger-action-select">
|
||||
<option value="notify"${existing && existing.action === "notify" ? " selected" : ""}>Notify</option>
|
||||
<option value="highlight"${existing && existing.action === "highlight" ? " selected" : ""}>Highlight tab</option>
|
||||
<option value="both"${existing && existing.action === "both" ? " selected" : ""}>Notify + Highlight</option>
|
||||
<option value="sound"${existing && existing.action === "sound" ? " selected" : ""}>Sound</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<input type="checkbox" class="trigger-case-check"${existing && existing.caseSensitive ? " checked" : ""} />
|
||||
Case-sensitive
|
||||
</label>
|
||||
</div>
|
||||
<div class="trigger-form-actions">
|
||||
<button class="btn btn-sm trigger-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-sm btn-accent trigger-save-btn">${existing ? "Save" : "Add"}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.parentNode.insertBefore(form, list);
|
||||
|
||||
form
|
||||
.querySelector(".trigger-cancel-btn")
|
||||
.addEventListener("click", () => {
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
if (toolbar) toolbar.style.display = "";
|
||||
});
|
||||
|
||||
form.querySelector(".trigger-save-btn").addEventListener("click", () => {
|
||||
const pattern = form
|
||||
.querySelector(".trigger-pattern-input")
|
||||
.value.trim();
|
||||
if (!pattern) {
|
||||
this.app.notifications.error("Pattern is required");
|
||||
return;
|
||||
}
|
||||
const rule = {
|
||||
name: form.querySelector(".trigger-name-input").value.trim(),
|
||||
pattern: pattern,
|
||||
isRegex: form.querySelector(".trigger-type-select").value === "regex",
|
||||
action: form.querySelector(".trigger-action-select").value,
|
||||
caseSensitive: form.querySelector(".trigger-case-check").checked,
|
||||
enabled: existing ? existing.enabled : true,
|
||||
};
|
||||
form.remove();
|
||||
list.style.display = "";
|
||||
if (toolbar) toolbar.style.display = "";
|
||||
onSave(rule);
|
||||
});
|
||||
|
||||
form.querySelector(".trigger-pattern-input").focus();
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
window.NexusTerminal.TriggerEngine = TriggerEngine;
|
||||
})();
|
||||
+154
-108
@@ -1,120 +1,132 @@
|
||||
(function() {
|
||||
/**
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — utility functions.
|
||||
* General-purpose helpers used throughout the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
/**
|
||||
* Shorthand for document.querySelector.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {Element|null}
|
||||
*/
|
||||
function $(selector) {
|
||||
function $(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Shorthand for document.querySelectorAll.
|
||||
* @param {string} selector - CSS selector.
|
||||
* @returns {NodeListOf<Element>}
|
||||
*/
|
||||
function $$(selector) {
|
||||
function $$(selector) {
|
||||
return document.querySelectorAll(selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Creates a DOM element with optional class name and attributes.
|
||||
* @param {string} tag - HTML tag name.
|
||||
* @param {string} [className] - Space-separated class names.
|
||||
* @param {Object} [attrs] - Attribute key/value pairs.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createElement(tag, className, attrs = {}) {
|
||||
function createElement(tag, className, attrs = {}) {
|
||||
const el = document.createElement(tag);
|
||||
if (className) {
|
||||
el.className = className;
|
||||
}
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === 'textContent') {
|
||||
if (key === "textContent") {
|
||||
el.textContent = value;
|
||||
} else if (key === 'innerHTML') {
|
||||
} else if (key === "innerHTML") {
|
||||
el.innerHTML = value;
|
||||
} else if (key === 'dataset') {
|
||||
} else if (key === "dataset") {
|
||||
for (const [dKey, dVal] of Object.entries(value)) {
|
||||
el.dataset[dKey] = dVal;
|
||||
}
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
} else if (key.startsWith("on") && typeof value === "function") {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - Raw string.
|
||||
* @returns {string} Escaped string safe for innerHTML.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return str.replace(/[&<>"']/g, (ch) => map[ch]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Formats a Date (or ISO string) into a concise timestamp.
|
||||
* @param {Date|string} date
|
||||
* @returns {string} e.g. "14:32:07" or "2 Jan 14:32".
|
||||
*/
|
||||
function formatTimestamp(date) {
|
||||
if (!date) return '';
|
||||
function formatTimestamp(date) {
|
||||
if (!date) return "";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
if (isNaN(d.getTime())) return "";
|
||||
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const mins = String(d.getMinutes()).padStart(2, '0');
|
||||
const secs = String(d.getSeconds()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
const secs = String(d.getSeconds()).padStart(2, "0");
|
||||
|
||||
if (isToday) {
|
||||
return `${hours}:${mins}:${secs}`;
|
||||
}
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
return `${d.getDate()} ${months[d.getMonth()]} ${hours}:${mins}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Returns a debounced version of the given function.
|
||||
* @param {Function} fn
|
||||
* @param {number} ms - Delay in milliseconds.
|
||||
* @returns {Function}
|
||||
*/
|
||||
function debounce(fn, ms) {
|
||||
function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Performs a simple fuzzy match — checks whether every character in
|
||||
* the query appears in order within the text (case-insensitive).
|
||||
* @param {string} text
|
||||
* @param {string} query
|
||||
* @returns {{ match: boolean, score: number }}
|
||||
*/
|
||||
function fuzzyMatch(text, query) {
|
||||
function fuzzyMatch(text, query) {
|
||||
if (!query) return { match: true, score: 1 };
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
@@ -140,152 +152,186 @@ function fuzzyMatch(text, query) {
|
||||
}
|
||||
|
||||
return { match: true, score };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Base64-encodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Encode(str) {
|
||||
function base64Encode(str) {
|
||||
return btoa(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Base64-decodes a string.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64Decode(str) {
|
||||
function base64Decode(str) {
|
||||
return atob(str);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Generates a new UUID v4 using the browser crypto API.
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateId() {
|
||||
function generateId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Formats a byte count into a human-friendly string.
|
||||
* @param {number} bytes
|
||||
* @returns {string} e.g. "1.23 KB"
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Truncates a string to the given length, appending an ellipsis if needed.
|
||||
* @param {string} str
|
||||
* @param {number} len
|
||||
* @returns {string}
|
||||
*/
|
||||
function truncate(str, len) {
|
||||
if (!str || str.length <= len) return str || '';
|
||||
return str.slice(0, len) + '…';
|
||||
}
|
||||
function truncate(str, len) {
|
||||
if (!str || str.length <= len) return str || "";
|
||||
return str.slice(0, len) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Removes any open context menus from the DOM.
|
||||
*/
|
||||
function dismissContextMenus() {
|
||||
document.querySelectorAll('.context-menu').forEach((m) => m.remove());
|
||||
}
|
||||
function dismissContextMenus() {
|
||||
document.querySelectorAll(".context-menu").forEach((m) => m.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Returns the protocol badge colour class.
|
||||
* @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'.
|
||||
* @returns {string}
|
||||
*/
|
||||
function protocolColour(protocol) {
|
||||
function protocolColour(protocol) {
|
||||
switch (protocol) {
|
||||
case 'ssh': return 'proto-ssh';
|
||||
case 'telnet': return 'proto-telnet';
|
||||
case 'raw': return 'proto-raw';
|
||||
case 'github': return 'proto-github';
|
||||
default: return '';
|
||||
case "ssh":
|
||||
return "proto-ssh";
|
||||
case "telnet":
|
||||
return "proto-telnet";
|
||||
case "raw":
|
||||
return "proto-raw";
|
||||
case "github":
|
||||
return "proto-github";
|
||||
case "vnc":
|
||||
return "proto-vnc";
|
||||
case "local":
|
||||
return "proto-local";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Returns a human-friendly relative time string for an ISO date.
|
||||
* @param {string} isoDate - ISO 8601 date string.
|
||||
* @returns {string} e.g. "3 hours ago", "2 days ago".
|
||||
*/
|
||||
function relativeTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
function relativeTime(isoDate) {
|
||||
if (!isoDate) return "";
|
||||
const d = new Date(isoDate);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
if (isNaN(d.getTime())) return "";
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`;
|
||||
if (mins < 60) return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`;
|
||||
if (hrs < 24) return hrs === 1 ? "1 hour ago" : `${hrs} hours ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||
if (days < 30) return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||
if (months < 12)
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
const years = Math.floor(months / 12);
|
||||
return years === 1 ? '1 year ago' : `${years} years ago`;
|
||||
}
|
||||
return years === 1 ? "1 year ago" : `${years} years ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Returns a human-friendly label for a session state.
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
function stateLabel(state) {
|
||||
function stateLabel(state) {
|
||||
switch (state) {
|
||||
case 'connecting': return 'Connecting';
|
||||
case 'connected': return 'Connected';
|
||||
case 'disconnected': return 'Disconnected';
|
||||
case 'reconnecting': return 'Reconnecting';
|
||||
case 'error': return 'Error';
|
||||
default: return state || 'Unknown';
|
||||
case "connecting":
|
||||
return "Connecting";
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "disconnected":
|
||||
return "Disconnected";
|
||||
case "reconnecting":
|
||||
return "Reconnecting";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return state || "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime });
|
||||
window.NexusTerminal = window.NexusTerminal || {};
|
||||
Object.assign(window.NexusTerminal, {
|
||||
$,
|
||||
$$,
|
||||
createElement,
|
||||
escapeHtml,
|
||||
formatTimestamp,
|
||||
debounce,
|
||||
fuzzyMatch,
|
||||
base64Encode,
|
||||
base64Decode,
|
||||
generateId,
|
||||
formatBytes,
|
||||
truncate,
|
||||
dismissContextMenus,
|
||||
protocolColour,
|
||||
stateLabel,
|
||||
relativeTime,
|
||||
});
|
||||
|
||||
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
|
||||
function disableAutoCapitalise(el) {
|
||||
el.setAttribute('autocapitalize', 'none');
|
||||
el.setAttribute('autocorrect', 'off');
|
||||
el.setAttribute('spellcheck', 'false');
|
||||
}
|
||||
/* ── Disable macOS/WebKit auto-capitalisation on all text inputs ───────── */
|
||||
function disableAutoCapitalise(el) {
|
||||
el.setAttribute("autocapitalize", "none");
|
||||
el.setAttribute("autocorrect", "off");
|
||||
el.setAttribute("spellcheck", "false");
|
||||
}
|
||||
|
||||
// Apply to existing inputs
|
||||
document.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
|
||||
// Apply to existing inputs
|
||||
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
|
||||
|
||||
// Observe for dynamically added inputs
|
||||
var inputObserver = new MutationObserver(function(mutations) {
|
||||
// Observe for dynamically added inputs
|
||||
var inputObserver = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var nodes = mutations[i].addedNodes;
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var node = nodes[j];
|
||||
if (node.nodeType !== 1) continue;
|
||||
if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') {
|
||||
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
|
||||
disableAutoCapitalise(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('input, textarea').forEach(disableAutoCapitalise);
|
||||
node
|
||||
.querySelectorAll("input, textarea")
|
||||
.forEach(disableAutoCapitalise);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
inputObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
});
|
||||
inputObserver.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
(function () {
|
||||
/**
|
||||
* Nexus Terminal — VNC viewer.
|
||||
* Renders a noVNC canvas inside a session panel, connecting to the local
|
||||
* WebSocket proxy started by the Go backend.
|
||||
*/
|
||||
|
||||
const { createElement, $ } = window.NexusTerminal;
|
||||
|
||||
class VNCViewer {
|
||||
constructor() {
|
||||
/** @type {Map<string, VNCViewerInstance>} sessionID → viewer */
|
||||
this._viewers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VNC viewer panel for a session inside the given container.
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container - The .session-panel element.
|
||||
* @param {object} profile - The profile object with VNC config.
|
||||
*/
|
||||
createViewer(sessionID, container, profile) {
|
||||
if (this._viewers.has(sessionID)) return;
|
||||
if (!container) return;
|
||||
|
||||
const viewer = new VNCViewerInstance(sessionID, container, profile);
|
||||
this._viewers.set(sessionID, viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the viewer to the WebSocket URL from the backend.
|
||||
* @param {string} sessionID
|
||||
* @param {string} wsURL
|
||||
*/
|
||||
connect(sessionID, wsURL) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.connect(wsURL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys a viewer for the given session.
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
destroyViewer(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
this._viewers.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the VNC display for the given session.
|
||||
* @param {string} sessionID
|
||||
*/
|
||||
resize(sessionID) {
|
||||
const viewer = this._viewers.get(sessionID);
|
||||
if (viewer) {
|
||||
viewer.resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VNCViewerInstance {
|
||||
/**
|
||||
* @param {string} sessionID
|
||||
* @param {HTMLElement} container
|
||||
* @param {object} profile
|
||||
*/
|
||||
constructor(sessionID, container, profile) {
|
||||
this.sessionID = sessionID;
|
||||
this.container = container;
|
||||
this.profile = profile;
|
||||
this.rfb = null;
|
||||
|
||||
this._buildUI();
|
||||
}
|
||||
|
||||
_buildUI() {
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.add("vnc-viewer-panel");
|
||||
|
||||
// Toolbar
|
||||
this.toolbar = createElement("div", "vnc-toolbar");
|
||||
|
||||
const ctrlAltDel = createElement("button", "btn btn-sm btn-vnc-action", {
|
||||
textContent: "Ctrl+Alt+Del",
|
||||
title: "Send Ctrl+Alt+Del",
|
||||
});
|
||||
ctrlAltDel.addEventListener("click", () => this._sendCtrlAltDel());
|
||||
|
||||
const clipboardBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-vnc-action",
|
||||
{
|
||||
textContent: "Clipboard",
|
||||
title: "Sync clipboard",
|
||||
},
|
||||
);
|
||||
clipboardBtn.addEventListener("click", () => this._syncClipboard());
|
||||
|
||||
const fullscreenBtn = createElement(
|
||||
"button",
|
||||
"btn btn-sm btn-vnc-action",
|
||||
{
|
||||
textContent: "Fullscreen",
|
||||
title: "Toggle fullscreen",
|
||||
},
|
||||
);
|
||||
fullscreenBtn.addEventListener("click", () => this._toggleFullscreen());
|
||||
|
||||
this.statusEl = createElement("span", "vnc-status", {
|
||||
textContent: "Connecting…",
|
||||
});
|
||||
|
||||
this.toolbar.appendChild(this.statusEl);
|
||||
this.toolbar.appendChild(ctrlAltDel);
|
||||
this.toolbar.appendChild(clipboardBtn);
|
||||
this.toolbar.appendChild(fullscreenBtn);
|
||||
|
||||
// VNC display container
|
||||
this.displayEl = createElement("div", "vnc-display");
|
||||
|
||||
this.container.appendChild(this.toolbar);
|
||||
this.container.appendChild(this.displayEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the VNC WebSocket proxy.
|
||||
* @param {string} wsURL - WebSocket URL (ws://127.0.0.1:<port>)
|
||||
*/
|
||||
connect(wsURL) {
|
||||
if (!window.noVNC || !window.noVNC.RFB) {
|
||||
this.statusEl.textContent = "Error: noVNC not loaded";
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const vncConfig = this.profile.vnc || {};
|
||||
|
||||
this.rfb = new window.noVNC.RFB(this.displayEl, wsURL, {
|
||||
shared: vncConfig.sharedSession !== false,
|
||||
credentials: {},
|
||||
});
|
||||
|
||||
this.rfb.viewOnly = !!vncConfig.viewOnly;
|
||||
this.rfb.scaleViewport = vncConfig.scaleToFit !== false;
|
||||
this.rfb.resizeSession = false;
|
||||
this.rfb.clipViewport = false;
|
||||
this.rfb.qualityLevel = vncConfig.quality || 6;
|
||||
this.rfb.compressionLevel = 2;
|
||||
|
||||
// Event handlers
|
||||
this.rfb.addEventListener("connect", () => {
|
||||
this.statusEl.textContent = "Connected";
|
||||
this.statusEl.classList.remove("vnc-status-error");
|
||||
this.statusEl.classList.add("vnc-status-connected");
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("disconnect", (ev) => {
|
||||
const clean = ev.detail.clean;
|
||||
this.statusEl.textContent = clean
|
||||
? "Disconnected"
|
||||
: "Connection lost";
|
||||
this.statusEl.classList.remove("vnc-status-connected");
|
||||
if (!clean) {
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
}
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("credentialsrequired", () => {
|
||||
this.statusEl.textContent = "Credentials required";
|
||||
// TODO: prompt for VNC password via modal
|
||||
this._promptCredentials();
|
||||
});
|
||||
|
||||
this.rfb.addEventListener("desktopname", (ev) => {
|
||||
this.statusEl.textContent = "Connected — " + ev.detail.name;
|
||||
});
|
||||
} catch (err) {
|
||||
this.statusEl.textContent = "Error: " + err.message;
|
||||
this.statusEl.classList.add("vnc-status-error");
|
||||
}
|
||||
}
|
||||
|
||||
_sendCtrlAltDel() {
|
||||
if (this.rfb) {
|
||||
this.rfb.sendCtrlAltDel();
|
||||
}
|
||||
}
|
||||
|
||||
async _syncClipboard() {
|
||||
if (!this.rfb) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
this.rfb.clipboardPasteFrom(text);
|
||||
}
|
||||
} catch (_) {
|
||||
// Clipboard API may not be available in WKWebView
|
||||
}
|
||||
}
|
||||
|
||||
_toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
this.container.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
_promptCredentials() {
|
||||
// Build a simple modal for VNC password
|
||||
const backdrop = createElement("div", "modal-backdrop");
|
||||
const modal = createElement("div", "modal modal-sm");
|
||||
modal.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">VNC Password</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-input" id="vnc-password-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="vnc-password-cancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="vnc-password-submit">Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(modal);
|
||||
requestAnimationFrame(() => {
|
||||
backdrop.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
});
|
||||
|
||||
const input = modal.querySelector("#vnc-password-input");
|
||||
const submit = modal.querySelector("#vnc-password-submit");
|
||||
const cancel = modal.querySelector("#vnc-password-cancel");
|
||||
|
||||
const close = () => {
|
||||
backdrop.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
setTimeout(() => {
|
||||
backdrop.remove();
|
||||
modal.remove();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
submit.addEventListener("click", () => {
|
||||
const pw = input.value;
|
||||
close();
|
||||
if (this.rfb && pw) {
|
||||
this.rfb.sendCredentials({ password: pw });
|
||||
}
|
||||
});
|
||||
|
||||
cancel.addEventListener("click", () => {
|
||||
close();
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit.click();
|
||||
if (e.key === "Escape") cancel.click();
|
||||
});
|
||||
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
resize() {
|
||||
// noVNC handles resizing internally via ResizeObserver on its canvas,
|
||||
// but we can trigger a re-scale if needed.
|
||||
if (this.rfb && this.rfb.scaleViewport) {
|
||||
this.rfb.scaleViewport = true;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.rfb) {
|
||||
this.rfb.disconnect();
|
||||
this.rfb = null;
|
||||
}
|
||||
this.container.innerHTML = "";
|
||||
this.container.classList.remove("vnc-viewer-panel");
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global namespace.
|
||||
window.NexusTerminal.VNCViewer = new VNCViewer();
|
||||
})();
|
||||
Vendored
+19
@@ -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>>;
|
||||
@@ -105,6 +122,8 @@ export function NewProfile(arg1:string):Promise<models.Profile>;
|
||||
|
||||
export function PinSession(arg1:string,arg2:boolean):Promise<void>;
|
||||
|
||||
export function PingSession(arg1:string):Promise<number>;
|
||||
|
||||
export function ReconnectSession(arg1:string):Promise<void>;
|
||||
|
||||
export function RefreshGitHubRepos(arg1:string):Promise<Array<models.GitHubRepo>>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
@@ -206,6 +238,10 @@ export function PinSession(arg1, arg2) {
|
||||
return window['go']['api']['Service']['PinSession'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function PingSession(arg1) {
|
||||
return window['go']['api']['Service']['PingSession'](arg1);
|
||||
}
|
||||
|
||||
export function ReconnectSession(arg1) {
|
||||
return window['go']['api']['Service']['ReconnectSession'](arg1);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -523,5 +592,6 @@ export namespace models {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,26 @@ module github.com/andydixon/nexus
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/zalando/go-keyring v0.2.5
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/danieljoos/wincred v1.2.0 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
@@ -42,5 +42,5 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -45,8 +45,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -63,11 +63,11 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -80,27 +80,27 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
|
||||
github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/exportimport"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/preferences"
|
||||
"github.com/andydixon/nexus/internal/secrets"
|
||||
@@ -368,6 +370,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
|
||||
return *info, nil
|
||||
}
|
||||
|
||||
// ConnectLocal opens a local shell session using the configured (or default) shell.
|
||||
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
|
||||
shell := ""
|
||||
if s.prefs != nil {
|
||||
prefs := s.prefs.Get()
|
||||
shell = prefs.LocalShell
|
||||
}
|
||||
sessionID, err := s.sessions.ConnectLocal(shell)
|
||||
if err != nil {
|
||||
return models.SessionInfo{}, err
|
||||
}
|
||||
info := s.sessions.GetSessionInfo(sessionID)
|
||||
if info == nil {
|
||||
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
|
||||
}
|
||||
return *info, nil
|
||||
}
|
||||
|
||||
// DisconnectSession closes a session by ID.
|
||||
func (s *Service) DisconnectSession(sessionID string) error {
|
||||
return s.sessions.Disconnect(sessionID)
|
||||
@@ -912,6 +932,39 @@ func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, r
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VNC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
||||
// The frontend connects noVNC to this URL for display.
|
||||
func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||
return s.sessions.GetVNCWebSocketURL(sessionID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetDefaultShell returns the system's default shell path.
|
||||
func (s *Service) GetDefaultShell() string {
|
||||
return localshell.DefaultShell()
|
||||
}
|
||||
|
||||
// BrowseForShell opens a file dialog to select a shell binary.
|
||||
func (s *Service) BrowseForShell() (string, error) {
|
||||
if s.ctx == nil {
|
||||
return "", fmt.Errorf("application context not set")
|
||||
}
|
||||
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Shell Binary",
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file dialog failed: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -931,6 +984,11 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error {
|
||||
return s.sessions.StopPortForward(sessionID, forwardID)
|
||||
}
|
||||
|
||||
// PingSession sends a keepalive request and returns the round-trip time in milliseconds.
|
||||
func (s *Service) PingSession(sessionID string) (int64, error) {
|
||||
return s.sessions.PingSession(sessionID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session recording
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1452,3 +1510,196 @@ func derefProfiles(ptrs []*models.Profile) []models.Profile {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSH Key Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SSHKeyInfo describes a key found in ~/.ssh/.
|
||||
type SSHKeyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Bits string `json:"bits"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
PrivateKey string `json:"privateKeyPath"`
|
||||
}
|
||||
|
||||
// ListSSHKeyPairs returns detailed info about SSH key pairs in ~/.ssh/.
|
||||
func (s *Service) ListSSHKeyPairs() ([]SSHKeyInfo, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
|
||||
entries, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []SSHKeyInfo{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unable to read .ssh directory: %w", err)
|
||||
}
|
||||
|
||||
var keys []SSHKeyInfo
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
// Look for .pub files and match them with private keys
|
||||
if !strings.HasSuffix(name, ".pub") {
|
||||
continue
|
||||
}
|
||||
baseName := strings.TrimSuffix(name, ".pub")
|
||||
privPath := filepath.Join(sshDir, baseName)
|
||||
|
||||
// Verify private key exists
|
||||
if _, err := os.Stat(privPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[baseName] {
|
||||
continue
|
||||
}
|
||||
seen[baseName] = true
|
||||
|
||||
// Read public key content
|
||||
pubBytes, err := os.ReadFile(filepath.Join(sshDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pubContent := strings.TrimSpace(string(pubBytes))
|
||||
|
||||
// Determine key type from public key content
|
||||
keyType := "unknown"
|
||||
parts := strings.Fields(pubContent)
|
||||
if len(parts) >= 1 {
|
||||
switch {
|
||||
case strings.Contains(parts[0], "ed25519"):
|
||||
keyType = "ed25519"
|
||||
case strings.Contains(parts[0], "ecdsa"):
|
||||
keyType = "ecdsa"
|
||||
case strings.Contains(parts[0], "rsa"):
|
||||
keyType = "rsa"
|
||||
case strings.Contains(parts[0], "dsa"):
|
||||
keyType = "dsa"
|
||||
}
|
||||
}
|
||||
|
||||
keys = append(keys, SSHKeyInfo{
|
||||
Name: baseName,
|
||||
Type: keyType,
|
||||
PublicKey: pubContent,
|
||||
PrivateKey: privPath,
|
||||
})
|
||||
}
|
||||
|
||||
if keys == nil {
|
||||
keys = []SSHKeyInfo{}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GenerateSSHKey generates a new SSH key pair.
|
||||
func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphrase string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("key name is required")
|
||||
}
|
||||
// Sanitise name — only allow alphanumeric, hyphens, underscores
|
||||
for _, ch := range name {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
|
||||
return fmt.Errorf("key name contains invalid characters (use alphanumeric, hyphens, underscores)")
|
||||
}
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
|
||||
// Ensure .ssh directory exists
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return fmt.Errorf("unable to create .ssh directory: %w", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(sshDir, name)
|
||||
// Check if key already exists
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return fmt.Errorf("key %q already exists", name)
|
||||
}
|
||||
|
||||
if keyType == "" {
|
||||
keyType = "ed25519"
|
||||
}
|
||||
|
||||
// Build ssh-keygen arguments
|
||||
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
|
||||
if passphrase != "" {
|
||||
args = append(args, "-N", passphrase)
|
||||
} else {
|
||||
args = append(args, "-N", "")
|
||||
}
|
||||
if keyType == "rsa" && bits > 0 {
|
||||
args = append(args, "-b", fmt.Sprintf("%d", bits))
|
||||
}
|
||||
|
||||
// #nosec G204 — arguments are sanitised above
|
||||
cmd := exec.Command("ssh-keygen", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSSHPublicKey returns the content of a public key file.
|
||||
func (s *Service) GetSSHPublicKey(name string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
pubPath := filepath.Join(home, ".ssh", name+".pub")
|
||||
data, err := os.ReadFile(pubPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to read public key: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// DeleteSSHKey removes an SSH key pair.
|
||||
func (s *Service) DeleteSSHKey(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("key name is required")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine home directory: %w", err)
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
privPath := filepath.Join(sshDir, name)
|
||||
pubPath := privPath + ".pub"
|
||||
|
||||
// Verify paths are within .ssh directory (prevent directory traversal)
|
||||
absPriv, _ := filepath.Abs(privPath)
|
||||
absSsh, _ := filepath.Abs(sshDir)
|
||||
if !strings.HasPrefix(absPriv, absSsh+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("invalid key name")
|
||||
}
|
||||
|
||||
var errs []string
|
||||
if err := os.Remove(privPath); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, "private key: "+err.Error())
|
||||
}
|
||||
if err := os.Remove(pubPath); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, "public key: "+err.Error())
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("failed to delete: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package localshell provides a local PTY-based terminal session.
|
||||
// It spawns a shell process and bridges I/O for the frontend terminal.
|
||||
package localshell
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// Client manages a local shell PTY session.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
shell string
|
||||
onData func([]byte)
|
||||
onError func(error)
|
||||
onClose func()
|
||||
closed bool
|
||||
cols uint16
|
||||
rows uint16
|
||||
}
|
||||
|
||||
// Config holds configuration for a local shell session.
|
||||
type Config struct {
|
||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
OnData func([]byte)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
}
|
||||
|
||||
// DefaultShell returns the system default shell.
|
||||
func DefaultShell() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Prefer PowerShell if available, else cmd.exe.
|
||||
if ps, err := exec.LookPath("powershell.exe"); err == nil {
|
||||
return ps
|
||||
}
|
||||
return "cmd.exe"
|
||||
}
|
||||
// Unix: use SHELL env var, fallback to /bin/sh.
|
||||
if sh := os.Getenv("SHELL"); sh != "" {
|
||||
return sh
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
// NewClient creates a new local shell client.
|
||||
func NewClient(cfg Config) *Client {
|
||||
shell := cfg.Shell
|
||||
if shell == "" {
|
||||
shell = DefaultShell()
|
||||
}
|
||||
cols := cfg.Cols
|
||||
if cols == 0 {
|
||||
cols = 80
|
||||
}
|
||||
rows := cfg.Rows
|
||||
if rows == 0 {
|
||||
rows = 24
|
||||
}
|
||||
return &Client{
|
||||
shell: shell,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start spawns the shell process and begins reading output.
|
||||
func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.ptmx != nil {
|
||||
return fmt.Errorf("shell already running")
|
||||
}
|
||||
|
||||
cmd := exec.Command(c.shell)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
// Start in the user's home directory.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
cmd.Dir = home
|
||||
}
|
||||
|
||||
// Set TERM for proper terminal behaviour.
|
||||
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
||||
Cols: c.cols,
|
||||
Rows: c.rows,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
|
||||
}
|
||||
|
||||
c.cmd = cmd
|
||||
c.ptmx = ptmx
|
||||
|
||||
// Read loop — forward PTY output to the frontend.
|
||||
go c.readLoop()
|
||||
|
||||
// Wait for process exit.
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if !wasClosed && c.onClose != nil {
|
||||
c.onClose()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends data to the shell's stdin (PTY).
|
||||
func (c *Client) Write(data []byte) error {
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
return fmt.Errorf("shell not running")
|
||||
}
|
||||
_, err := ptmx.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Resize updates the PTY window size.
|
||||
func (c *Client) Resize(cols, rows uint16) error {
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
return nil
|
||||
}
|
||||
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
// Close terminates the shell process and PTY.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
|
||||
if c.ptmx != nil {
|
||||
c.ptmx.Close()
|
||||
c.ptmx = nil
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
c.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := c.ptmx.Read(buf)
|
||||
if n > 0 && c.onData != nil {
|
||||
c.onData(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.mu.Unlock()
|
||||
if !wasClosed && c.onError != nil {
|
||||
c.onError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
DefaultTelnetPort = 23
|
||||
DefaultRAWPort = 23
|
||||
DefaultGitHubPort = 443
|
||||
DefaultVNCPort = 5900
|
||||
)
|
||||
|
||||
// Default timeout values (in seconds).
|
||||
@@ -50,6 +51,7 @@ func DefaultAppPreferences() AppPreferences {
|
||||
DefaultScrollback: DefaultScrollback,
|
||||
DefaultCursorStyle: DefaultCursorStyle,
|
||||
UseNativePaste: false,
|
||||
LocalShell: "", // empty means use system default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +110,18 @@ func NewProfile(protocol Protocol) Profile {
|
||||
SecretMode: SecretModeSaved,
|
||||
RefreshIntervalSecs: 300,
|
||||
}
|
||||
case ProtocolVNC:
|
||||
p.Port = DefaultVNCPort
|
||||
p.VNC = &VNCConfig{
|
||||
VNCPort: DefaultVNCPort,
|
||||
SecretMode: SecretModeSaved,
|
||||
ScaleToFit: true,
|
||||
SharedSession: true,
|
||||
Quality: 6,
|
||||
SSHPort: DefaultSSHPort,
|
||||
SSHAuthMethod: "publickey",
|
||||
SSHSecretMode: SecretModeSaved,
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
ProtocolTelnet Protocol = "telnet"
|
||||
ProtocolRAW Protocol = "raw"
|
||||
ProtocolGitHub Protocol = "github"
|
||||
ProtocolVNC Protocol = "vnc"
|
||||
ProtocolLocal Protocol = "local"
|
||||
)
|
||||
|
||||
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
|
||||
@@ -80,6 +82,9 @@ type Profile struct {
|
||||
|
||||
// GitHub-specific configuration
|
||||
GitHub *GitHubConfig `json:"github,omitempty"`
|
||||
|
||||
// VNC-specific configuration
|
||||
VNC *VNCConfig `json:"vnc,omitempty"`
|
||||
}
|
||||
|
||||
// SSHConfig holds SSH-specific settings for a connection profile.
|
||||
@@ -192,6 +197,40 @@ type GitHubConfig struct {
|
||||
RefreshIntervalSecs int `json:"refreshIntervalSecs"`
|
||||
}
|
||||
|
||||
// VNCConfig holds VNC-specific settings for a connection profile.
|
||||
type VNCConfig struct {
|
||||
// VNCHost is the VNC server address (defaults to profile.Host if empty).
|
||||
VNCHost string `json:"vncHost"`
|
||||
|
||||
// VNCPort is the VNC server port (5900 + display number, default 5900).
|
||||
VNCPort int `json:"vncPort"`
|
||||
|
||||
// SecretMode controls how the VNC password is stored.
|
||||
SecretMode SecretMode `json:"secretMode"`
|
||||
|
||||
// ViewOnly disables sending keyboard and mouse events to the server.
|
||||
ViewOnly bool `json:"viewOnly"`
|
||||
|
||||
// ScaleToFit scales the remote framebuffer to fit the panel.
|
||||
ScaleToFit bool `json:"scaleToFit"`
|
||||
|
||||
// Quality controls JPEG compression quality (1-9, 0 = default).
|
||||
Quality int `json:"quality"`
|
||||
|
||||
// SharedSession allows other VNC viewers to remain connected.
|
||||
SharedSession bool `json:"sharedSession"`
|
||||
|
||||
// SSH tunnel settings — if enabled, VNC traffic is tunnelled through an SSH connection.
|
||||
SSHTunnel bool `json:"sshTunnel"`
|
||||
SSHHost string `json:"sshHost"`
|
||||
SSHPort int `json:"sshPort"`
|
||||
SSHUsername string `json:"sshUsername"`
|
||||
SSHAuthMethod string `json:"sshAuthMethod"` // password, publickey
|
||||
SSHSecretMode SecretMode `json:"sshSecretMode"`
|
||||
SSHPrivateKey string `json:"sshPrivateKeyPath"`
|
||||
SSHAgentAuth bool `json:"sshAgentAuth"`
|
||||
}
|
||||
|
||||
// GitHubRepo is a single repository returned to the frontend for display.
|
||||
type GitHubRepo struct {
|
||||
FullName string `json:"fullName"`
|
||||
@@ -264,6 +303,7 @@ type AppPreferences struct {
|
||||
DefaultScrollback int `json:"defaultScrollback"`
|
||||
DefaultCursorStyle string `json:"defaultCursorStyle"`
|
||||
UseNativePaste bool `json:"useNativePaste"`
|
||||
LocalShell string `json:"localShell"` // path to shell binary for local terminal
|
||||
}
|
||||
|
||||
// AuditEntry records an auditable action within the application.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
|
||||
"github.com/andydixon/nexus/internal/audit"
|
||||
"github.com/andydixon/nexus/internal/githubclient"
|
||||
"github.com/andydixon/nexus/internal/localshell"
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
"github.com/andydixon/nexus/internal/rawclient"
|
||||
"github.com/andydixon/nexus/internal/recording"
|
||||
@@ -24,6 +27,7 @@ import (
|
||||
"github.com/andydixon/nexus/internal/sshclient"
|
||||
"github.com/andydixon/nexus/internal/storage"
|
||||
"github.com/andydixon/nexus/internal/telnetclient"
|
||||
"github.com/andydixon/nexus/internal/vncclient"
|
||||
)
|
||||
|
||||
// Session wraps a protocol client with metadata and state.
|
||||
@@ -34,6 +38,8 @@ type Session struct {
|
||||
rawClient *rawclient.Client
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
|
||||
localClient *localshell.Client // local shell PTY, nil for non-local sessions
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
recorder *recording.Recorder // active recording, nil if not recording
|
||||
@@ -123,6 +129,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
||||
err = m.connectRAW(sess)
|
||||
case models.ProtocolGitHub:
|
||||
err = m.connectGitHub(sess, getSecret)
|
||||
case models.ProtocolVNC:
|
||||
err = m.connectVNC(sess, getSecret)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||
}
|
||||
@@ -157,6 +165,104 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// ConnectLocal creates a local shell session without a saved profile.
|
||||
// The shell path is resolved from the given parameter or falls back to the system default.
|
||||
func (m *Manager) ConnectLocal(shell string) (string, error) {
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
info := models.SessionInfo{
|
||||
ID: sessionID,
|
||||
ProfileID: "",
|
||||
ProfileName: "Local Machine",
|
||||
Protocol: models.ProtocolLocal,
|
||||
Host: "localhost",
|
||||
Port: 0,
|
||||
State: models.SessionConnecting,
|
||||
TabName: "Local",
|
||||
ConnectedAt: now,
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
Info: info,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[sessionID] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionConnecting),
|
||||
})
|
||||
|
||||
go func() {
|
||||
client := localshell.NewClient(localshell.Config{
|
||||
Shell: shell,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
OnData: func(data []byte) {
|
||||
m.emitEvent("session:data", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"data": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
// Write to active recording if present.
|
||||
m.mu.RLock()
|
||||
rec := sess.recorder
|
||||
m.mu.RUnlock()
|
||||
if rec != nil {
|
||||
rec.WriteOutput(data)
|
||||
}
|
||||
},
|
||||
OnError: func(err error) {
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
OnClose: func() {
|
||||
m.handleSessionClosed(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionError
|
||||
sess.Info.Error = err.Error()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sess.localClient = client
|
||||
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionConnected
|
||||
sess.Info.ConnectedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"state": string(models.SessionConnected),
|
||||
})
|
||||
|
||||
m.auditLog.LogConnect("", sessionID, "local", "localhost")
|
||||
}()
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
// Disconnect closes a specific session.
|
||||
func (m *Manager) Disconnect(sessionID string) error {
|
||||
m.mu.Lock()
|
||||
@@ -234,6 +340,8 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
|
||||
return sess.telnetClient.Write(data)
|
||||
case sess.rawClient != nil:
|
||||
return sess.rawClient.Write(data)
|
||||
case sess.localClient != nil:
|
||||
return sess.localClient.Write(data)
|
||||
}
|
||||
return fmt.Errorf("sessions: no active client for session %q", sessionID)
|
||||
}
|
||||
@@ -253,6 +361,8 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
|
||||
return sess.sshClient.Resize(cols, rows)
|
||||
case sess.telnetClient != nil:
|
||||
return sess.telnetClient.Resize(cols, rows)
|
||||
case sess.localClient != nil:
|
||||
return sess.localClient.Resize(uint16(cols), uint16(rows))
|
||||
}
|
||||
// RAW sessions have no concept of terminal size.
|
||||
return nil
|
||||
@@ -867,6 +977,116 @@ func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref s
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectVNC establishes a VNC connection (with optional SSH tunnel) and
|
||||
// emits the local WebSocket proxy URL to the frontend.
|
||||
func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error {
|
||||
profile := sess.profile
|
||||
vncConf := profile.VNC
|
||||
if vncConf == nil {
|
||||
return fmt.Errorf("VNC configuration missing")
|
||||
}
|
||||
|
||||
// Fall back to the profile-level host if VNC-specific host is empty.
|
||||
vncHost := vncConf.VNCHost
|
||||
if vncHost == "" {
|
||||
vncHost = profile.Host
|
||||
}
|
||||
|
||||
cfg := vncclient.Config{
|
||||
VNCHost: vncHost,
|
||||
VNCPort: vncConf.VNCPort,
|
||||
OnError: func(err error) {
|
||||
m.emitEvent("session:state", map[string]interface{}{
|
||||
"sessionID": sess.Info.ID,
|
||||
"state": string(models.SessionError),
|
||||
"error": err.Error(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// SSH tunnel configuration
|
||||
if vncConf.SSHTunnel {
|
||||
cfg.SSHTunnel = true
|
||||
cfg.SSHHost = vncConf.SSHHost
|
||||
cfg.SSHPort = vncConf.SSHPort
|
||||
cfg.SSHUsername = vncConf.SSHUsername
|
||||
cfg.SSHAuthMethod = vncConf.SSHAuthMethod
|
||||
cfg.SSHAgentAuth = vncConf.SSHAgentAuth
|
||||
|
||||
// Resolve SSH credentials
|
||||
switch vncConf.SSHAuthMethod {
|
||||
case "password":
|
||||
getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret)
|
||||
password, err := getPass()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH password: %w", err)
|
||||
}
|
||||
cfg.SSHPassword = password
|
||||
case "publickey":
|
||||
if vncConf.SSHPrivateKey != "" {
|
||||
keyData, err := os.ReadFile(vncConf.SSHPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SSH key: %w", err)
|
||||
}
|
||||
cfg.SSHPrivateKey = keyData
|
||||
// Get passphrase if needed
|
||||
getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret)
|
||||
passphrase, _ := getPassphrase()
|
||||
cfg.SSHPassphrase = passphrase
|
||||
}
|
||||
}
|
||||
|
||||
// SSH agent support
|
||||
if vncConf.SSHAgentAuth {
|
||||
socketPath := os.Getenv("SSH_AUTH_SOCK")
|
||||
if socketPath != "" {
|
||||
agentConn, err := net.Dial("unix", socketPath)
|
||||
if err == nil {
|
||||
cfg.SSHAgentConn = agentConn
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct VNC — may need a VNC password
|
||||
getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret)
|
||||
password, _ := getVNCPass()
|
||||
// VNC auth is handled by the RFB protocol in the frontend (noVNC),
|
||||
// but we store it in case we need server-side auth negotiation later.
|
||||
_ = password
|
||||
}
|
||||
|
||||
client := vncclient.NewClient(cfg)
|
||||
wsURL, err := client.Connect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sess.vncClient = client
|
||||
|
||||
// Emit the WebSocket URL so the frontend can connect noVNC.
|
||||
m.emitEvent("session:vnc:ready", map[string]interface{}{
|
||||
"sessionID": sess.Info.ID,
|
||||
"wsURL": wsURL,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session.
|
||||
func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) {
|
||||
m.mu.RLock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return "", fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.vncClient == nil {
|
||||
return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID)
|
||||
}
|
||||
return sess.vncClient.WSURL(), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session recording
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1069,6 +1289,7 @@ func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error {
|
||||
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"direction": "download",
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
@@ -1086,6 +1307,7 @@ func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error {
|
||||
return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"direction": "upload",
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
@@ -1103,6 +1325,7 @@ func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) err
|
||||
return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"direction": "upload",
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
@@ -1202,6 +1425,22 @@ func (m *Manager) StopPortForward(sessionID string, forwardID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PingSession sends a keepalive request to the server and returns the RTT in milliseconds.
|
||||
func (m *Manager) PingSession(sessionID string) (int64, error) {
|
||||
m.mu.RLock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.sshClient == nil {
|
||||
return 0, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||
}
|
||||
|
||||
return sess.sshClient.Ping()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1351,6 +1590,14 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
if sess.githubCancel != nil {
|
||||
sess.githubCancel()
|
||||
}
|
||||
if sess.vncClient != nil {
|
||||
sess.vncClient.Close()
|
||||
sess.vncClient = nil
|
||||
}
|
||||
if sess.localClient != nil {
|
||||
sess.localClient.Close()
|
||||
sess.localClient = nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionDisconnected
|
||||
|
||||
@@ -159,8 +159,9 @@ func (c *Client) connectInternal(
|
||||
rows := 24
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
ssh.TTY_OP_ISPEED: 115200,
|
||||
ssh.TTY_OP_OSPEED: 115200,
|
||||
ssh.IUTF8: 1,
|
||||
}
|
||||
|
||||
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
|
||||
@@ -261,6 +262,24 @@ func (c *Client) Resize(cols, rows int) error {
|
||||
return session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Ping sends a keepalive request and returns the round-trip time in milliseconds.
|
||||
func (c *Client) Ping() (int64, error) {
|
||||
c.mu.Lock()
|
||||
if !c.connected || c.sshClient == nil {
|
||||
c.mu.Unlock()
|
||||
return 0, fmt.Errorf("not connected")
|
||||
}
|
||||
client := c.sshClient
|
||||
c.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return time.Since(start).Milliseconds(), nil
|
||||
}
|
||||
|
||||
// RunCommand executes a single command using a new SSH session.
|
||||
func (c *Client) RunCommand(command string) (string, error) {
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Migration 004: Add VNC protocol support
|
||||
-- Adds the 'vnc' value to the protocol CHECK constraint and a vnc_config column.
|
||||
-- Step 1: Create the new table with the updated constraint.
|
||||
CREATE TABLE IF NOT EXISTS profiles_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
protocol TEXT NOT NULL CHECK(
|
||||
protocol IN ('ssh', 'telnet', 'raw', 'github', 'vnc')
|
||||
),
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
folder TEXT DEFAULT '',
|
||||
environment TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
favourite INTEGER DEFAULT 0,
|
||||
last_connected TEXT,
|
||||
connect_timeout INTEGER DEFAULT 30,
|
||||
read_timeout INTEGER DEFAULT 0,
|
||||
terminal_font_family TEXT DEFAULT '',
|
||||
terminal_font_size INTEGER DEFAULT 14,
|
||||
terminal_scrollback INTEGER DEFAULT 10000,
|
||||
terminal_cursor_style TEXT DEFAULT 'block',
|
||||
ssh_config TEXT,
|
||||
telnet_config TEXT,
|
||||
raw_config TEXT,
|
||||
github_config TEXT,
|
||||
vnc_config TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Step 2: Copy existing data (vnc_config will be NULL for legacy rows).
|
||||
INSERT
|
||||
OR IGNORE INTO profiles_new (
|
||||
id,
|
||||
display_name,
|
||||
description,
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
tags,
|
||||
folder,
|
||||
environment,
|
||||
notes,
|
||||
favourite,
|
||||
last_connected,
|
||||
connect_timeout,
|
||||
read_timeout,
|
||||
terminal_font_family,
|
||||
terminal_font_size,
|
||||
terminal_scrollback,
|
||||
terminal_cursor_style,
|
||||
ssh_config,
|
||||
telnet_config,
|
||||
raw_config,
|
||||
github_config,
|
||||
vnc_config,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
display_name,
|
||||
description,
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
tags,
|
||||
folder,
|
||||
environment,
|
||||
notes,
|
||||
favourite,
|
||||
last_connected,
|
||||
connect_timeout,
|
||||
read_timeout,
|
||||
terminal_font_family,
|
||||
terminal_font_size,
|
||||
terminal_scrollback,
|
||||
terminal_cursor_style,
|
||||
ssh_config,
|
||||
telnet_config,
|
||||
raw_config,
|
||||
github_config,
|
||||
NULL,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
profiles;
|
||||
|
||||
-- Step 3: Drop old table and rename.
|
||||
DROP TABLE profiles;
|
||||
|
||||
ALTER TABLE
|
||||
profiles_new RENAME TO profiles;
|
||||
|
||||
-- Step 4: Record the migration version.
|
||||
INSERT INTO
|
||||
schema_version (version, applied_at)
|
||||
VALUES
|
||||
(4, datetime('now'));
|
||||
+26
-16
@@ -110,15 +110,16 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
|
||||
_, err := s.db.Exec(`INSERT INTO profiles (
|
||||
id, display_name, description, protocol, host, port, username,
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
p.ID, p.DisplayName, p.Description, string(p.Protocol),
|
||||
p.Host, p.Port, p.Username,
|
||||
string(tagsJSON), p.Folder, p.Environment, p.Notes,
|
||||
@@ -126,7 +127,7 @@ func (s *Store) CreateProfile(p *models.Profile) error {
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
nullableJSON(p.GitHub, githubJSON),
|
||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
||||
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -142,7 +143,7 @@ func (s *Store) GetProfile(id string) (*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE id = ?`, id)
|
||||
|
||||
@@ -163,13 +164,14 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
telnetJSON, _ := json.Marshal(p.Telnet)
|
||||
rawJSON, _ := json.Marshal(p.RAW)
|
||||
githubJSON, _ := json.Marshal(p.GitHub)
|
||||
vncJSON, _ := json.Marshal(p.VNC)
|
||||
|
||||
res, err := s.db.Exec(`UPDATE profiles SET
|
||||
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
|
||||
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
|
||||
connect_timeout=?, read_timeout=?,
|
||||
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?,
|
||||
ssh_config=?, telnet_config=?, raw_config=?, github_config=?, vnc_config=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
p.DisplayName, p.Description, string(p.Protocol),
|
||||
@@ -179,7 +181,7 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
p.ConnectTimeout, p.ReadTimeout,
|
||||
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
|
||||
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
|
||||
nullableJSON(p.GitHub, githubJSON),
|
||||
nullableJSON(p.GitHub, githubJSON), nullableJSON(p.VNC, vncJSON),
|
||||
p.UpdatedAt.Format(time.RFC3339),
|
||||
p.ID,
|
||||
)
|
||||
@@ -213,7 +215,7 @@ func (s *Store) ListProfiles() ([]*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -232,7 +234,7 @@ func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles
|
||||
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
|
||||
@@ -251,7 +253,7 @@ func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, erro
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
|
||||
if err != nil {
|
||||
@@ -269,7 +271,7 @@ func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
|
||||
if err != nil {
|
||||
@@ -286,7 +288,7 @@ func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
|
||||
if err != nil {
|
||||
@@ -303,7 +305,7 @@ func (s *Store) ListFavourites() ([]*models.Profile, error) {
|
||||
tags, folder, environment, notes, favourite, last_connected,
|
||||
connect_timeout, read_timeout,
|
||||
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
|
||||
ssh_config, telnet_config, raw_config, github_config,
|
||||
ssh_config, telnet_config, raw_config, github_config, vnc_config,
|
||||
created_at, updated_at
|
||||
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
|
||||
if err != nil {
|
||||
@@ -566,7 +568,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -579,7 +581,7 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -618,6 +620,10 @@ func scanProfile(row *sql.Row) (*models.Profile, error) {
|
||||
p.GitHub = &models.GitHubConfig{}
|
||||
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
|
||||
}
|
||||
if vncJSON.Valid && vncJSON.String != "" {
|
||||
p.VNC = &models.VNCConfig{}
|
||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -629,7 +635,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
p := &models.Profile{}
|
||||
var (
|
||||
protocol, tagsJSON string
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON sql.NullString
|
||||
sshJSON, telnetJSON, rawJSON, githubJSON, vncJSON sql.NullString
|
||||
lastConn sql.NullString
|
||||
favourite int
|
||||
createdAt, updatedAt string
|
||||
@@ -642,7 +648,7 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
&favourite, &lastConn,
|
||||
&p.ConnectTimeout, &p.ReadTimeout,
|
||||
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON,
|
||||
&sshJSON, &telnetJSON, &rawJSON, &githubJSON, &vncJSON,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -681,6 +687,10 @@ func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
|
||||
p.GitHub = &models.GitHubConfig{}
|
||||
_ = json.Unmarshal([]byte(githubJSON.String), p.GitHub)
|
||||
}
|
||||
if vncJSON.Valid && vncJSON.String != "" {
|
||||
p.VNC = &models.VNCConfig{}
|
||||
_ = json.Unmarshal([]byte(vncJSON.String), p.VNC)
|
||||
}
|
||||
|
||||
result = append(result, p)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ func ValidateProfile(p *models.Profile) error {
|
||||
}
|
||||
case models.ProtocolGitHub:
|
||||
// GitHub profiles require no host-level validation beyond the common checks.
|
||||
case models.ProtocolVNC:
|
||||
// VNC profiles use vnc.vncHost or the profile-level host; validated at connect time.
|
||||
default:
|
||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// Package vncclient provides a WebSocket-to-TCP proxy for VNC connections,
|
||||
// with optional SSH tunnel support.
|
||||
package vncclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// Client manages a VNC session, optionally tunnelled through SSH.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
listener net.Listener
|
||||
sshClient *ssh.Client
|
||||
wsURL string
|
||||
connected bool
|
||||
onError func(error)
|
||||
}
|
||||
|
||||
// Config holds the configuration for a VNC connection.
|
||||
type Config struct {
|
||||
// VNC server address
|
||||
VNCHost string
|
||||
VNCPort int
|
||||
|
||||
// SSH tunnel settings
|
||||
SSHTunnel bool
|
||||
SSHHost string
|
||||
SSHPort int
|
||||
SSHUsername string
|
||||
SSHAuthMethod string // "password", "publickey"
|
||||
SSHPassword string
|
||||
SSHPrivateKey []byte
|
||||
SSHPassphrase string
|
||||
SSHAgentAuth bool
|
||||
SSHAgentConn net.Conn
|
||||
|
||||
// Callbacks
|
||||
OnError func(error)
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
ReadBufferSize: 65536,
|
||||
WriteBufferSize: 65536,
|
||||
}
|
||||
|
||||
// NewClient creates a new VNC proxy client.
|
||||
func NewClient(cfg Config) *Client {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Client{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
onError: cfg.OnError,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the VNC connection (with optional SSH tunnel) and starts
|
||||
// a local WebSocket proxy. Returns the WebSocket URL for the frontend.
|
||||
func (c *Client) Connect(cfg Config) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.connected {
|
||||
return c.wsURL, nil
|
||||
}
|
||||
|
||||
var vncConn net.Conn
|
||||
var err error
|
||||
|
||||
vncAddr := fmt.Sprintf("%s:%d", cfg.VNCHost, cfg.VNCPort)
|
||||
|
||||
if cfg.SSHTunnel {
|
||||
// Establish SSH connection first
|
||||
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.SSHUsername,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: use known hosts
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
// Configure auth methods
|
||||
var authMethods []ssh.AuthMethod
|
||||
if cfg.SSHAgentAuth && cfg.SSHAgentConn != nil {
|
||||
agent := newSSHAgent(cfg.SSHAgentConn)
|
||||
if agent != nil {
|
||||
authMethods = append(authMethods, agent)
|
||||
}
|
||||
}
|
||||
if len(cfg.SSHPrivateKey) > 0 {
|
||||
var signer ssh.Signer
|
||||
if cfg.SSHPassphrase != "" {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(cfg.SSHPrivateKey, []byte(cfg.SSHPassphrase))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKey(cfg.SSHPrivateKey)
|
||||
}
|
||||
if err == nil {
|
||||
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||
}
|
||||
}
|
||||
if cfg.SSHPassword != "" {
|
||||
authMethods = append(authMethods, ssh.Password(cfg.SSHPassword))
|
||||
}
|
||||
sshConfig.Auth = authMethods
|
||||
|
||||
c.sshClient, err = ssh.Dial("tcp", sshAddr, sshConfig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("SSH connection failed: %w", err)
|
||||
}
|
||||
|
||||
// Dial VNC through the SSH tunnel
|
||||
// When tunnelling, VNC address is relative to the SSH server
|
||||
tunnelAddr := vncAddr
|
||||
if cfg.VNCHost == "" || cfg.VNCHost == cfg.SSHHost {
|
||||
tunnelAddr = fmt.Sprintf("127.0.0.1:%d", cfg.VNCPort)
|
||||
}
|
||||
vncConn, err = c.sshClient.Dial("tcp", tunnelAddr)
|
||||
if err != nil {
|
||||
c.sshClient.Close()
|
||||
c.sshClient = nil
|
||||
return "", fmt.Errorf("VNC tunnel dial failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Direct TCP connection to VNC server
|
||||
vncConn, err = net.DialTimeout("tcp", vncAddr, 15*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("VNC connection failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start local WebSocket proxy
|
||||
wsURL, err := c.startProxy(vncConn)
|
||||
if err != nil {
|
||||
vncConn.Close()
|
||||
if c.sshClient != nil {
|
||||
c.sshClient.Close()
|
||||
c.sshClient = nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to start WebSocket proxy: %w", err)
|
||||
}
|
||||
|
||||
c.wsURL = wsURL
|
||||
c.connected = true
|
||||
return wsURL, nil
|
||||
}
|
||||
|
||||
// startProxy creates a local HTTP server that upgrades to WebSocket and bridges
|
||||
// traffic to the VNC TCP connection.
|
||||
func (c *Client) startProxy(vncConn net.Conn) (string, error) {
|
||||
// Listen on a random available port on localhost
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.listener = listener
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
wsURL := fmt.Sprintf("ws://127.0.0.1:%d", port)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
// Bridge WebSocket ↔ VNC TCP in both directions
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
// VNC → WebSocket
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, err := vncConn.Read(buf)
|
||||
if n > 0 {
|
||||
if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket → VNC
|
||||
go func() {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := vncConn.Write(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for either direction to close
|
||||
select {
|
||||
case <-done:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
ReadTimeout: 0, // VNC is long-lived
|
||||
WriteTimeout: 0,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
if c.onError != nil {
|
||||
c.onError(fmt.Errorf("VNC proxy error: %w", err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Close server when context is cancelled
|
||||
go func() {
|
||||
<-c.ctx.Done()
|
||||
server.Close()
|
||||
vncConn.Close()
|
||||
}()
|
||||
|
||||
return wsURL, nil
|
||||
}
|
||||
|
||||
// Close shuts down the VNC proxy and any SSH tunnel.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.cancel()
|
||||
c.connected = false
|
||||
|
||||
if c.listener != nil {
|
||||
c.listener.Close()
|
||||
c.listener = nil
|
||||
}
|
||||
if c.sshClient != nil {
|
||||
c.sshClient.Close()
|
||||
c.sshClient = nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsConnected returns whether the VNC session is active.
|
||||
func (c *Client) IsConnected() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// WSURL returns the WebSocket URL for the frontend to connect to.
|
||||
func (c *Client) WSURL() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.wsURL
|
||||
}
|
||||
|
||||
// newSSHAgent creates an SSH agent auth method from the given connection.
|
||||
func newSSHAgent(conn net.Conn) ssh.AuthMethod {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
ag := agent.NewClient(conn)
|
||||
signers, err := ag.Signers()
|
||||
if err != nil || len(signers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ssh.PublicKeys(signers...)
|
||||
}
|
||||
+81
-8
@@ -18,6 +18,53 @@ log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
|
||||
host_os() {
|
||||
case "$(uname -s)" in
|
||||
Darwin) echo "darwin" ;;
|
||||
Linux) echo "linux" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
host_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo "amd64" ;;
|
||||
arm64|aarch64) echo "arm64" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_tags() {
|
||||
if command -v pkg-config >/dev/null 2>&1 \
|
||||
&& pkg-config --exists webkit2gtk-4.1 \
|
||||
&& ! pkg-config --exists webkit2gtk-4.0; then
|
||||
echo "webkit2_41"
|
||||
fi
|
||||
}
|
||||
|
||||
default_targets() {
|
||||
local os
|
||||
local arch
|
||||
os="$(host_os)"
|
||||
arch="$(host_arch)"
|
||||
|
||||
if [ "$os" = "unknown" ] || [ "$arch" = "unknown" ]; then
|
||||
err "Unsupported host platform: $(uname -s)/$(uname -m)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${os}/${arch}"
|
||||
}
|
||||
|
||||
release_targets() {
|
||||
if [ -n "${NEXUS_RELEASE_TARGETS:-}" ]; then
|
||||
echo "$NEXUS_RELEASE_TARGETS" | tr ',' '\n' | sed '/^[[:space:]]*$/d'
|
||||
return
|
||||
fi
|
||||
|
||||
default_targets
|
||||
}
|
||||
|
||||
check_deps() {
|
||||
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
|
||||
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
|
||||
@@ -132,6 +179,8 @@ build_target() {
|
||||
log "Building ${platform}..."
|
||||
local ldflags
|
||||
ldflags="$(build_ldflags "$version")"
|
||||
local tags
|
||||
tags="$(build_tags)"
|
||||
if [ -n "$cc" ]; then
|
||||
local cc_cmd="$cc"
|
||||
if [ -n "$zig_target" ]; then
|
||||
@@ -141,12 +190,22 @@ build_target() {
|
||||
cc_cmd="zig cc -target ${zig_target}-windows-gnu"
|
||||
fi
|
||||
fi
|
||||
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
if [ -n "$tags" ]; then
|
||||
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
elif ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
if [ -n "$tags" ]; then
|
||||
if ! CGO_ENABLED=1 wails build -platform "$platform" -tags "$tags" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
elif ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
@@ -273,13 +332,27 @@ main() {
|
||||
log "Building release ${version}..."
|
||||
: > "${out_dir}/.release-files"
|
||||
|
||||
local targets
|
||||
mapfile -t targets < <(release_targets)
|
||||
if [ "${#targets[@]}" -eq 0 ]; then
|
||||
err "No release targets selected."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local errors=0
|
||||
if ! build_target "darwin" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "darwin" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "linux" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "linux" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "windows" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "windows" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
local target
|
||||
for target in "${targets[@]}"; do
|
||||
local target_os="${target%%/*}"
|
||||
local target_arch="${target##*/}"
|
||||
if [ "$target_os" = "$target_arch" ] || [ -z "$target_os" ] || [ -z "$target_arch" ]; then
|
||||
err "Invalid release target: ${target}"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
if ! build_target "$target_os" "$target_arch" "$version" "$out_dir"; then
|
||||
errors=1
|
||||
fi
|
||||
done
|
||||
|
||||
write_manifest "$version" "$out_dir" "$release_root"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user