Remove the Go backend, JS frontend, Wails config and old build scripts; release.sh now wraps rust/release.sh

This commit is contained in:
Andy Dixon
2026-10-11 17:43:55 +01:00
parent e1707e4751
commit 1c16079584
225 changed files with 20 additions and 79839 deletions
+6 -50
View File
@@ -1,42 +1,10 @@
# ==========================================================================
# Alchementary Link — .gitignore
# Nexus Terminal — .gitignore
# ==========================================================================
# ── Build output ──────────────────────────────────────────────────────────
build/bin/
dist/
# ── VSCode extensions: platform-specific build artifacts (never commit) ────
# The Go bridge is compiled per-host by vscode/build.sh and staged into each
# extension's bin/ — committing it ships a binary for the wrong OS/arch.
vscode/bin/
vscode/dist/
vscode/*/bin/
# ── Build platform resources (tracked — icons, plists, manifests) ─────────
# build/darwin/, build/windows/, build/linux/ are tracked for icons & metadata
# ── Go ────────────────────────────────────────────────────────────────────
vendor/
.tools/
.cache/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
__debug_bin*
coverage.out
coverage.html
# ── Frontend ──────────────────────────────────────────────────────────────
frontend/node_modules/
frontend/src/js/lib/
# ── Wails auto-generated bindings (recreated on every build) ──────────────
frontend/wailsjs/
rust/target/
build/releases/
# ── IDE / editor ──────────────────────────────────────────────────────────
.idea/
@@ -44,20 +12,12 @@ frontend/wailsjs/
*.swp
*.swo
*~
*.sublime-project
*.sublime-workspace
.project
.classpath
.settings/
# ── OS metadata ───────────────────────────────────────────────────────────
.DS_Store
.DS_Store?
._*
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# ── Application data (never commit user data or secrets) ──────────────────
*.db
@@ -66,13 +26,9 @@ $RECYCLE.BIN/
vault.json
*.pem
*.key
!build/appicon.png
# Builds
build/releases/v*
build/releases/manifest*
# Rust port compatibility fixtures (generated by cmd/genfixtures)
# Compatibility fixtures generated from the last Go build are deliberate test data.
!rust/core/tests/fixtures/**
# Agent worktrees
.claude/worktrees/
-89
View File
@@ -1,89 +0,0 @@
.PHONY: all build build-compressed dev clean test test-cover lint install deps frontend-deps upx-check run package release help
# Resolve the Wails binary. Prefer wails on PATH; fall back to GOPATH/bin.
WAILS := $(or $(shell which wails 2>/dev/null),$(shell go env GOPATH)/bin/wails)
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev)-$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LD_UPDATE_URL := $(if $(LINK_UPDATE_URL),-X github.com/andydixon/link/internal/api.appUpdateURL=$(LINK_UPDATE_URL),)
UPX_FLAGS ?=
LINK_FORCE_MACOS_UPX ?= 0
GOBIN_DIR := $(shell go env GOPATH)/bin
export PATH := $(GOBIN_DIR):$(PATH)
# Default target
all: build
# Install all dependencies
deps: frontend-deps
go mod download
upx-check:
@command -v upx >/dev/null 2>&1 || { echo "UPX is required for compressed builds. Install it first, for example: brew install upx"; exit 1; }
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(LINK_FORCE_MACOS_UPX)" != "1" ]; then \
echo "UPX compression is disabled for macOS by default. Use ./release.sh for a zipped .app, or run LINK_FORCE_MACOS_UPX=1 UPX_FLAGS='--force-macos --best --no-color --no-progress' make build-compressed."; \
exit 1; \
fi
@if [ "$$(uname -s)" = "Darwin" ] && [ "$(LINK_FORCE_MACOS_UPX)" = "1" ] && ! printf '%s\n' "$(UPX_FLAGS)" | grep -q -- '--force-macos'; then \
echo "macOS UPX builds must pass --force-macos in UPX_FLAGS."; \
exit 1; \
fi
# Install frontend dependencies
frontend-deps:
cd frontend && npm install
# Development mode with hot reload
dev: deps
$(WAILS) dev
# Production build
build: deps
CGO_ENABLED=0 $(WAILS) build -trimpath -ldflags "-s -w -buildid= -X github.com/andydixon/link/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
# Production build with UPX compression
build-compressed: deps upx-check
CGO_ENABLED=0 $(WAILS) build -trimpath -upx $(if $(UPX_FLAGS),-upxflags "$(UPX_FLAGS)",) -ldflags "-s -w -buildid= -X github.com/andydixon/link/internal/api.appVersion=$(VERSION) $(LD_UPDATE_URL)"
# Run tests
test:
go test ./... -v -count=1
# Run tests with coverage
test-cover:
go test ./... -v -count=1 -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
# Clean build artefacts
clean:
rm -rf build/bin
rm -rf frontend/node_modules
rm -rf frontend/src/js/lib
rm -rf frontend/src/css/lib
go clean -testcache
# Lint (requires golangci-lint)
lint:
golangci-lint run ./...
# Package for distribution
package: build
@echo "Binary built at build/bin/"
# Build and package release artefacts for all platforms
release:
./release.sh
# Help
help:
@echo "Alchementary Link — Build Targets"
@echo ""
@echo " make deps — Install all dependencies"
@echo " make dev — Run in development mode with hot reload"
@echo " make build — Production build"
@echo " make build-compressed — Production build with UPX compression"
@echo " make test — Run tests"
@echo " make test-cover — Run tests with coverage report"
@echo " make clean — Clean build artefacts"
@echo " make lint — Run linter (requires golangci-lint)"
@echo " make package — Build and package for distribution"
@echo " make release — Build multi-platform release artefacts"
@echo " make help — Show this help"
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env bash
# build-all.sh — Cross-build helper for Alchementary Link (macOS/Linux hosts)
# Builds all supported targets where the required toolchains are installed.
# Usage: ./build-all.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
source "$SCRIPT_DIR/scripts/build-common.sh"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[link]${NC} $1"; }
warn() { echo -e "${YELLOW}[link]${NC} $1"; }
err() { echo -e "${RED}[link]${NC} $1" >&2; }
MIN_GO_VERSION="1.25.12"
WAILS_VERSION="$(awk '$1 == "github.com/wailsapp/wails/v2" { print $2; exit }' go.mod)"
WAILS_BIN=""
WAILS_USE_GO_RUN=0
BUILT_TARGETS=0
SKIPPED_TARGETS=0
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
fi
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
mkdir -p "$GOCACHE" "$GOPATH"
export PATH="$GOPATH/bin:$PATH"
version_at_least() {
local have="${1#go}"
local want="$2"
local IFS=.
local have_parts want_parts
read -r -a have_parts <<< "$have"
read -r -a want_parts <<< "$want"
for i in 0 1 2; do
local h="${have_parts[$i]:-0}"
local w="${want_parts[$i]:-0}"
if (( h > w )); then return 0; fi
if (( h < w )); then return 1; fi
done
return 0
}
check_go() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
local version
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
exit 1
fi
}
resolve_wails() {
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
WAILS_USE_GO_RUN=0
return
fi
local gopath
gopath="$(go env GOPATH 2>/dev/null || true)"
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
WAILS_BIN="$gopath/bin/wails"
WAILS_USE_GO_RUN=0
return
fi
if [ -n "$WAILS_VERSION" ]; then
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
WAILS_USE_GO_RUN=1
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
return
fi
err "Wails is not installed and no Wails version was found in go.mod"
exit 1
}
wails_cmd() {
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
go run "$WAILS_BIN" "$@"
else
"$WAILS_BIN" "$@"
fi
}
ensure_upx() {
if ! link_bool_enabled "${LINK_COMPRESS:-0}"; then
return
fi
if command -v upx >/dev/null 2>&1; then
return
fi
warn "UPX is not installed; compressed targets will be skipped unless the target does not use UPX."
}
linux_deps_ok() {
command -v pkg-config >/dev/null 2>&1 || return 1
pkg-config --exists gtk+-3.0 || return 1
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
return 1
fi
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
return 1
fi
}
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
tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")"
commit="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
echo "${tag}-${commit}"
}
build_ldflags() {
local version="$1"
local flags="-s -w -X github.com/andydixon/link/internal/api.appVersion=${version}"
if [ -n "${LINK_UPDATE_URL:-}" ]; then
flags="${flags} -X github.com/andydixon/link/internal/api.appUpdateURL=${LINK_UPDATE_URL}"
fi
echo "${flags}"
}
check_deps() {
check_go
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
resolve_wails
ensure_upx
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
}
install_deps() {
log "Installing Go dependencies…"
go mod download
log "Installing frontend dependencies…"
if [ -f frontend/package-lock.json ]; then
(cd frontend && npm ci)
else
(cd frontend && npm install)
fi
}
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_target() {
local target_os="$1"
local target_arch="$2"
local host="$3"
local host_arch_value="$4"
local version="$5"
local platform="${target_os}/${target_arch}"
link_configure_pure_go_build
local ldflags
ldflags="$(build_ldflags "${version}")"
local tags
tags="$(build_tags)"
local build_args=("-platform" "$target_os/$target_arch" "-trimpath")
if link_use_upx_for_target "$target_os"; then
if ! command -v upx >/dev/null 2>&1; then
warn "Skipping ${platform} (LINK_COMPRESS=1 requires UPX)"
((SKIPPED_TARGETS += 1))
return 0
fi
build_args+=("-upx")
if [ -n "${LINK_UPX_FLAGS:-}" ]; then
build_args+=("-upxflags" "$LINK_UPX_FLAGS")
fi
elif link_skips_upx_for_target "$target_os"; then
warn "Skipping UPX compression for ${platform}; distribute the zipped .app instead, or set LINK_FORCE_MACOS_UPX=1 with suitable LINK_UPX_FLAGS."
fi
if [ -n "$tags" ]; then
build_args+=("-tags" "$tags")
fi
build_args+=("-ldflags" "$ldflags")
if [ "$target_os" = "linux" ] && ! linux_deps_ok; then
warn "Skipping ${platform} (missing Linux GTK/WebKit build dependencies)"
((SKIPPED_TARGETS += 1))
return 0
fi
log "Building ${platform}…"
CGO_ENABLED=0 wails_cmd build "${build_args[@]}"
((BUILT_TARGETS += 1))
}
main() {
check_deps
install_deps
local version
version="$(build_version)"
local host
host="$(host_os)"
local arch
arch="$(host_arch)"
if [ "$host" = "unknown" ] || [ "$arch" = "unknown" ]; then
err "Unsupported host platform: $(uname -s)/$(uname -m)"
exit 1
fi
build_target "$host" "$arch" "$host" "$arch" "$version"
# Cross-build Linux and Windows targets where toolchains are available
if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then
for target in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
local target_os="${target%%/*}"
local target_arch="${target##*/}"
if [ "$target_os" = "$host" ] && [ "$target_arch" = "$arch" ]; then
continue
fi
build_target "$target_os" "$target_arch" "$host" "$arch" "$version"
done
fi
if [ "$BUILT_TARGETS" -eq 0 ]; then
err "No targets were built; ${SKIPPED_TARGETS} target(s) were skipped because required system toolchains are missing."
exit 1
fi
log "Builds complete (${BUILT_TARGETS} built, ${SKIPPED_TARGETS} skipped). Output: build/bin/"
}
main "$@"
-139
View File
@@ -1,139 +0,0 @@
# build.ps1 — Windows build script for Alchementary Link
# Usage: .\build.ps1 [dev|build|test|clean|deps]
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Info($Message) { Write-Host "[link] $Message" -ForegroundColor Green }
function Write-Warn($Message) { Write-Host "[link] $Message" -ForegroundColor Yellow }
function Write-Err($Message) { Write-Host "[link] $Message" -ForegroundColor Red }
function Test-BoolEnabled([string]$Value) {
return $Value -match '^(1|true|TRUE|yes|YES|on|ON)$'
}
function Get-WailsPath {
$wails = Get-Command wails -ErrorAction SilentlyContinue
if ($wails) { return $wails.Source }
$goPath = & go env GOPATH
$fallback = Join-Path $goPath 'bin\wails.exe'
if (Test-Path $fallback) { return $fallback }
return $null
}
function Check-Deps {
if (-not (Get-Command go -ErrorAction SilentlyContinue)) { throw 'Go is not installed.' }
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js is not installed.' }
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { throw 'npm is not installed.' }
$wailsPath = Get-WailsPath
if (-not $wailsPath) {
throw 'Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest'
}
Write-Info "Go: $(& go version)"
Write-Info "Node: $(& node --version)"
Write-Info "Wails: $(& $wailsPath version | Select-Object -First 1)"
return $wailsPath
}
function Install-Deps {
Write-Info 'Installing Go dependencies...'
& go mod download
Write-Info 'Installing frontend dependencies...'
Push-Location 'frontend'
& npm install
Pop-Location
}
function Get-BuildVersion {
try {
$tag = & git describe --tags --abbrev=0 2>$null
if (-not $tag) { $tag = 'dev' }
} catch {
$tag = 'dev'
}
try {
$commit = & git rev-parse --short HEAD 2>$null
if (-not $commit) { $commit = 'unknown' }
} catch {
$commit = 'unknown'
}
return "$tag-$commit"
}
function Get-Ldflags([string]$Version) {
$flags = "-s -w -buildid= -X github.com/andydixon/link/internal/api.appVersion=$Version"
if ($env:LINK_UPDATE_URL) {
$flags += " -X github.com/andydixon/link/internal/api.appUpdateURL=$($env:LINK_UPDATE_URL)"
}
return $flags
}
function Dev {
$wailsPath = Check-Deps
Install-Deps
Write-Info 'Starting development mode...'
& $wailsPath dev
}
function Build {
$wailsPath = Check-Deps
Install-Deps
$version = Get-BuildVersion
$ldflags = Get-Ldflags $version
$env:CGO_ENABLED = '0'
Remove-Item Env:CGO_CFLAGS -ErrorAction SilentlyContinue
Remove-Item Env:CGO_CXXFLAGS -ErrorAction SilentlyContinue
$buildArgs = @('build', '-trimpath')
if (Test-BoolEnabled $env:LINK_COMPRESS) {
if (-not (Get-Command upx -ErrorAction SilentlyContinue)) {
throw 'LINK_COMPRESS=1 requires UPX. Install it first.'
}
$buildArgs += '-upx'
if ($env:LINK_UPX_FLAGS) {
$buildArgs += @('-upxflags', $env:LINK_UPX_FLAGS)
}
}
$buildArgs += @('-ldflags', $ldflags)
Write-Info 'Building production binary (CGO_ENABLED=0)...'
& $wailsPath @buildArgs
Write-Info 'Build complete: build\bin\'
}
function Run-Tests {
Write-Info 'Running tests...'
& go test ./... -v -count=1
}
function Clean {
Write-Info 'Cleaning build artefacts...'
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue build\bin
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\node_modules
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\src\js\lib
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue frontend\src\css\lib
& go clean -testcache
Write-Info 'Clean complete'
}
$task = $args[0]
if (-not $task) { $task = 'build' }
switch ($task) {
'dev' { Dev }
'build' { Build }
'test' { Run-Tests }
'clean' { Clean }
'deps' { Check-Deps | Out-Null; Install-Deps }
default {
Write-Err 'Usage: .\\build.ps1 [dev|build|test|clean|deps]'
exit 1
}
}
-267
View File
@@ -1,267 +0,0 @@
#!/usr/bin/env bash
# build.sh — Build script for Alchementary Link
# Provides a reproducible build process for CI and local builds.
# Usage: ./build.sh [dev|build|test|clean|deps]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
source "$SCRIPT_DIR/scripts/build-common.sh"
# Colours for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[link]${NC} $1"; }
warn() { echo -e "${YELLOW}[link]${NC} $1"; }
err() { echo -e "${RED}[link]${NC} $1" >&2; }
MIN_GO_VERSION="1.25.12"
WAILS_VERSION="$(link_wails_version go.mod)"
WAILS_BIN=""
WAILS_USE_GO_RUN=0
if [ -x "$SCRIPT_DIR/.tools/go/bin/go" ]; then
export PATH="$SCRIPT_DIR/.tools/go/bin:$PATH"
fi
export GOCACHE="${GOCACHE:-$SCRIPT_DIR/.cache/go-build}"
export GOPATH="${GOPATH:-$SCRIPT_DIR/.cache/go}"
mkdir -p "$GOCACHE" "$GOPATH"
export PATH="$GOPATH/bin:$PATH"
version_at_least() {
local have="${1#go}"
local want="$2"
local IFS=.
local have_parts want_parts
read -r -a have_parts <<< "$have"
read -r -a want_parts <<< "$want"
for i in 0 1 2; do
local h="${have_parts[$i]:-0}"
local w="${want_parts[$i]:-0}"
if (( h > w )); then return 0; fi
if (( h < w )); then return 1; fi
done
return 0
}
check_go() {
command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; }
local version
version="$(go env GOVERSION 2>/dev/null || go version | awk '{print $3}')"
if ! version_at_least "$version" "$MIN_GO_VERSION"; then
err "Go ${MIN_GO_VERSION}+ is required by go.mod; found ${version} at $(command -v go)"
err "Install a newer Go, or place it at .tools/go/bin/go for this project."
exit 1
fi
}
resolve_wails() {
if WAILS_BIN="$(command -v wails 2>/dev/null)"; then
WAILS_USE_GO_RUN=0
return
fi
local gopath
gopath="$(go env GOPATH 2>/dev/null || true)"
if [ -n "$gopath" ] && [ -x "$gopath/bin/wails" ]; then
WAILS_BIN="$gopath/bin/wails"
WAILS_USE_GO_RUN=0
return
fi
if [ -n "$WAILS_VERSION" ]; then
WAILS_BIN="github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}"
WAILS_USE_GO_RUN=1
warn "Wails CLI not found; using pinned ${WAILS_BIN} via go run"
return
fi
err "Wails is not installed and no Wails version was found in go.mod"
exit 1
}
wails_cmd() {
if [ "$WAILS_USE_GO_RUN" -eq 1 ]; then
go run "$WAILS_BIN" "$@"
else
"$WAILS_BIN" "$@"
fi
}
ensure_upx() {
if ! link_bool_enabled "${LINK_COMPRESS:-0}"; then
return
fi
if link_skips_upx_for_target "$(host_os)"; then
return
fi
if command -v upx >/dev/null 2>&1; then
return
fi
err "LINK_COMPRESS=1 requires UPX. Install it first, for example: brew install upx"
exit 1
}
check_linux_deps() {
if [ "$(uname -s)" != "Linux" ]; then
return
fi
command -v pkg-config >/dev/null 2>&1 || {
err "pkg-config is required for Linux Wails builds."
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
exit 1
}
local missing=()
pkg-config --exists gtk+-3.0 || missing+=("gtk+-3.0")
if ! pkg-config --exists webkit2gtk-4.1 && ! pkg-config --exists webkit2gtk-4.0; then
missing+=("webkit2gtk-4.1 or webkit2gtk-4.0")
fi
if ! pkg-config --exists ayatana-appindicator3-0.1 && ! pkg-config --exists appindicator3-0.1; then
missing+=("ayatana-appindicator3-0.1 or appindicator3-0.1")
fi
if [ "${#missing[@]}" -gt 0 ]; then
err "Missing Linux build dependencies: ${missing[*]}"
err "On Ubuntu/Pop!_OS install: sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev"
exit 1
fi
}
build_tags() {
link_build_tags
}
host_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
build_version() {
link_build_version
}
build_ldflags() {
link_build_ldflags "$1"
}
# Check prerequisites
check_deps() {
check_go
command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; }
command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; }
check_linux_deps
resolve_wails
ensure_upx
log "All prerequisites found"
log "Go: $(go version)"
log "Node: $(node --version)"
log "Wails: $(wails_cmd version 2>/dev/null | head -1)"
}
# Install dependencies
install_deps() {
log "Installing Go dependencies…"
go mod download
log "Installing frontend dependencies…"
if [ -f frontend/package-lock.json ]; then
(cd frontend && npm ci)
else
(cd frontend && npm install)
fi
log "Dependencies installed"
}
# Development mode
dev() {
check_deps
install_deps
log "Starting development mode…"
wails_cmd dev
}
# Production build
build() {
check_deps
install_deps
log "Building production binary…"
local version
version="$(build_version)"
local tags
tags="$(build_tags)"
link_configure_pure_go_build
local build_args=("-trimpath")
if link_bool_enabled "${LINK_COMPRESS:-0}"; then
if link_skips_upx_for_target "$(host_os)"; then
warn "Skipping UPX compression on macOS; distribute the zipped .app instead, or set LINK_FORCE_MACOS_UPX=1 with suitable LINK_UPX_FLAGS."
else
log "Using UPX compression"
build_args+=("-upx")
if [ -n "${LINK_UPX_FLAGS:-}" ]; then
build_args+=("-upxflags" "$LINK_UPX_FLAGS")
fi
fi
fi
if [ -n "$tags" ]; then
log "Using Wails build tags: ${tags}"
wails_cmd build "${build_args[@]}" -tags "$tags" -ldflags "$(build_ldflags "${version}")"
else
wails_cmd build "${build_args[@]}" -ldflags "$(build_ldflags "${version}")"
fi
# Replace Wails-generated icon with custom app icon on macOS.
if [[ "$OSTYPE" == darwin* ]]; then
local app_bundle="build/bin/alchementary-link.app"
local custom_icns="build/darwin/AppIcon.icns"
if [ -d "$app_bundle" ] && [ -f "$custom_icns" ]; then
cp "$custom_icns" "$app_bundle/Contents/Resources/iconfile.icns"
log "Replaced app icon with custom AppIcon.icns"
fi
fi
log "Build complete: build/bin/"
}
# Run tests
run_tests() {
check_go
log "Running tests…"
go test ./... -v -count=1
(cd frontend && npm test)
}
# Clean build artefacts
clean() {
log "Cleaning build artefacts…"
rm -rf build/bin
rm -rf frontend/node_modules
rm -rf frontend/src/js/lib
if command -v go >/dev/null 2>&1; then
go clean -testcache
fi
log "Clean complete"
}
# Main
case "${1:-build}" in
dev) dev ;;
build) build ;;
test) run_tests ;;
clean) clean ;;
deps) check_deps && install_deps ;;
*)
echo "Usage: $0 [dev|build|test|clean|deps]"
exit 1
;;
esac
-137
View File
@@ -1,137 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<!-- Background gradient: deep navy -->
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0D1117;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1A1F2E;stop-opacity:1" />
</linearGradient>
<!-- Cyan glow for terminal elements -->
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#00D4FF;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00FF88;stop-opacity:1" />
</linearGradient>
<!-- Inner glow effect for terminal text -->
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Subtle glow for grid lines -->
<filter id="softGlow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Clip to rounded rect -->
<clipPath id="roundedClip">
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224"/>
</clipPath>
</defs>
<!-- Rounded rectangle background -->
<rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="url(#bgGrad)"/>
<!-- Clipped content group -->
<g clip-path="url(#roundedClip)">
<!-- Subtle grid lines (circuit board / network feel) -->
<g opacity="0.07" stroke="#00D4FF" stroke-width="1.5" filter="url(#softGlow)">
<!-- Horizontal lines -->
<line x1="0" y1="200" x2="1024" y2="200"/>
<line x1="0" y1="350" x2="1024" y2="350"/>
<line x1="0" y1="500" x2="1024" y2="500"/>
<line x1="0" y1="650" x2="1024" y2="650"/>
<line x1="0" y1="800" x2="1024" y2="800"/>
<!-- Vertical lines -->
<line x1="200" y1="0" x2="200" y2="1024"/>
<line x1="350" y1="0" x2="350" y2="1024"/>
<line x1="500" y1="0" x2="500" y2="1024"/>
<line x1="650" y1="0" x2="650" y2="1024"/>
<line x1="800" y1="0" x2="800" y2="1024"/>
</g>
<!-- Network node dots at grid intersections (subtle) -->
<g opacity="0.12" fill="#00D4FF">
<circle cx="200" cy="200" r="4"/>
<circle cx="350" cy="200" r="4"/>
<circle cx="500" cy="200" r="4"/>
<circle cx="650" cy="200" r="4"/>
<circle cx="800" cy="200" r="4"/>
<circle cx="200" cy="350" r="4"/>
<circle cx="350" cy="350" r="4"/>
<circle cx="650" cy="350" r="4"/>
<circle cx="800" cy="350" r="4"/>
<circle cx="200" cy="500" r="4"/>
<circle cx="800" cy="500" r="4"/>
<circle cx="200" cy="650" r="4"/>
<circle cx="350" cy="650" r="4"/>
<circle cx="650" cy="650" r="4"/>
<circle cx="800" cy="650" r="4"/>
<circle cx="200" cy="800" r="4"/>
<circle cx="350" cy="800" r="4"/>
<circle cx="500" cy="800" r="4"/>
<circle cx="650" cy="800" r="4"/>
<circle cx="800" cy="800" r="4"/>
</g>
<!-- Central terminal window panel -->
<rect x="112" y="240" width="800" height="544" rx="16" ry="16"
fill="#0A0E17" fill-opacity="0.85"
stroke="#00D4FF" stroke-width="1.5" stroke-opacity="0.3"/>
<!-- Terminal title bar -->
<rect x="112" y="240" width="800" height="52" rx="16" ry="16"
fill="#161B27" fill-opacity="0.9"/>
<!-- Fix bottom corners of title bar -->
<rect x="112" y="268" width="800" height="24"
fill="#161B27" fill-opacity="0.9"/>
<!-- Window control dots -->
<circle cx="162" cy="266" r="10" fill="#FF5F57"/>
<circle cx="198" cy="266" r="10" fill="#FFBD2E"/>
<circle cx="234" cy="266" r="10" fill="#28C840"/>
<!-- Title bar text: "link ssh" -->
<text x="512" y="273" font-family="'SF Mono', 'Courier New', monospace"
font-size="20" fill="#8892A4" text-anchor="middle" dominant-baseline="middle"
letter-spacing="3">link — ssh</text>
<!-- Terminal separator line -->
<line x1="112" y1="292" x2="912" y2="292" stroke="#00D4FF" stroke-width="1" stroke-opacity="0.2"/>
<!-- MAIN TERMINAL CONTENT -->
<!-- Line 1: prompt line -->
<text x="152" y="360" font-family="'SF Mono', 'Courier New', monospace"
font-size="38" fill="#8892A4" filter="url(#softGlow)">user@link:~$</text>
<!-- THE BIG PROMPT SYMBOL: ">_" — cyan glowing, centered -->
<text x="512" y="560" font-family="'SF Mono', 'Courier New', monospace"
font-size="260" font-weight="bold"
fill="url(#cyanGrad)"
text-anchor="middle" dominant-baseline="middle"
filter="url(#glow)">&gt;_</text>
<!-- Blinking cursor line at bottom of terminal -->
<rect x="152" y="726" width="22" height="36" rx="2" ry="2"
fill="#00D4FF" opacity="0.9" filter="url(#softGlow)"/>
<!-- Bottom status line -->
<text x="740" y="748" font-family="'SF Mono', 'Courier New', monospace"
font-size="22" fill="#00FF88" fill-opacity="0.6" text-anchor="end">SSH v2 AES-256</text>
</g>
<!-- Outer border glow (subtle cyan rim) -->
<rect x="2" y="2" width="1020" height="1020" rx="223" ry="223"
fill="none"
stroke="url(#cyanGrad)" stroke-width="4" stroke-opacity="0.25"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.alchementary.link</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

-15
View File
@@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
-89
View File
@@ -1,89 +0,0 @@
// Command genfixtures writes the Go-generated compatibility fixtures that the
// Rust port must read unchanged. Run from the repo root:
//
// go run ./cmd/genfixtures rust/core/tests/fixtures
package main
import (
"log"
"os"
"path/filepath"
"time"
"github.com/andydixon/link/internal/exportimport"
"github.com/andydixon/link/internal/models"
"github.com/andydixon/link/internal/recording"
"github.com/andydixon/link/internal/secrets"
"github.com/andydixon/link/internal/storage"
)
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
out := os.Args[1]
must(os.RemoveAll(out))
must(os.MkdirAll(out, 0o755))
store, err := storage.NewStore(filepath.Join(out, "link.db"))
must(err)
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var profiles []models.Profile
for i, proto := range []models.Protocol{models.ProtocolSSH, models.ProtocolTelnet, models.ProtocolRAW, models.ProtocolGitHub, models.ProtocolVNC, models.ProtocolIRC} {
p := models.NewProfile(proto)
p.ID = "00000000-0000-0000-0000-00000000000" + string(rune('1'+i))
p.DisplayName = "Fixture " + string(proto)
p.Host = "host-" + string(proto) + ".example"
p.Tags = []string{"fixture", string(proto)}
p.Folder = "Fixtures"
p.Notes = "notes ✓"
p.CreatedAt, p.UpdatedAt = ts, ts
switch proto {
case models.ProtocolSSH:
p.Username = "alice"
p.Favourite = true
p.LastConnected = &ts
p.SSH.PrivateKeyPath = "/home/alice/.ssh/id_ed25519"
p.SSH.JumpHosts = []models.JumpHost{{Host: "jump.example", Port: 2222, Username: "bob", AuthMethod: "publickey", SecretMode: models.SecretModeSaved}}
p.SSH.PortForwards = []models.PortForward{{ID: "pf1", Direction: models.PortForwardLocal, BindHost: "127.0.0.1", BindPort: 8080, DestHost: "localhost", DestPort: 80, Enabled: true, Label: "web"}}
p.SSH.EnvVars = map[string]string{"LANG": "en_GB.UTF-8"}
p.SSH.LoginScripts = []models.LoginScriptStep{{Type: "wait", Value: "$ ", Timeout: 5}, {Type: "send", Value: "uptime"}}
p.SSH.ProxyType = "socks5"
p.SSH.ProxyHost = "proxy.example"
p.SSH.ProxyPort = 1080
p.SSH.AutoReconnect = true
case models.ProtocolRAW:
p.RAW.PayloadPresets = []models.PayloadPreset{{ID: "pp1", Name: "hello", Payload: "aGVsbG8=", Format: "text"}}
case models.ProtocolIRC:
p.IRC.AutoJoinRooms = []string{"#rust", "#go"}
p.IRC.UseTLS = true
}
must(store.CreateProfile(&p))
profiles = append(profiles, p)
}
must(store.SaveKnownHost(&models.KnownHost{ID: "kh1", Host: "host-ssh.example", Port: 22, KeyType: "ssh-ed25519", Fingerprint: "SHA256:abc", PublicKey: "AAAA", FirstSeen: ts, LastSeen: ts, Trusted: true}))
must(store.CreateSnippet(&models.Snippet{ID: "sn1", Name: "uptime", Command: "uptime", Description: "load", Folder: "ops", Tags: []string{"x"}, CreatedAt: ts, UpdatedAt: ts}))
must(store.SaveIRCLastRooms(profiles[5].ID, []string{"#rust", "#nexus"}))
prefs := models.DefaultAppPreferences()
prefs.TerminalTheme = "dracula"
prefs.SidebarWidth = 320
must(store.SavePreferences(&prefs))
must(store.Close())
vault := secrets.NewSecretStore(filepath.Join(out, "vault"))
must(vault.Initialise("correct horse battery staple"))
must(vault.SetSecret("profile:00000000-0000-0000-0000-000000000001:password", "s3cret-ssh"))
must(vault.SetSecret("profile:00000000-0000-0000-0000-000000000001:totp", "JBSWY3DPEHPK3PXP"))
must(exportimport.EncryptAndExport(profiles, "export-pass", filepath.Join(out, "profiles.link")))
rec, err := recording.NewRecorder(filepath.Join(out, "recordings"), "rec1", "Fixture — host", 80, 24)
must(err)
rec.WriteOutput([]byte("hello \x1b[1mworld\x1b[0m\r\n"))
rec.WriteInput([]byte("ls\r"))
must(rec.Close())
log.Println("fixtures written to", out)
}
-357
View File
@@ -1,357 +0,0 @@
// Command vscode-bridge exposes the Link backend to VS Code extensions over a
// localhost-only JSON-RPC and event stream.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"reflect"
"strings"
"sync"
"syscall"
"time"
"github.com/andydixon/link/internal/api"
"github.com/andydixon/link/internal/audit"
"github.com/andydixon/link/internal/config"
"github.com/andydixon/link/internal/preferences"
"github.com/andydixon/link/internal/recording"
"github.com/andydixon/link/internal/secrets"
"github.com/andydixon/link/internal/sessions"
"github.com/andydixon/link/internal/storage"
)
type rpcRequest struct {
Method string `json:"method"`
Args []json.RawMessage `json:"args"`
}
type rpcResponse struct {
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type runtimeEventRequest struct {
Name string `json:"name"`
Payload map[string]any `json:"payload"`
}
const (
bridgeReadHeaderTimeout = 5 * time.Second
bridgeReadTimeout = 15 * time.Second
bridgeIdleTimeout = 60 * time.Second
bridgeMaxHeaderBytes = 16 << 10
bridgeMaxJSONBodyBytes = 1 << 20
)
type eventBroker struct {
mu sync.Mutex
clients map[chan []byte]struct{}
}
func newEventBroker() *eventBroker {
return &eventBroker{clients: make(map[chan []byte]struct{})}
}
func (b *eventBroker) publish(name string, payload any) {
msg, err := json.Marshal(map[string]any{"name": name, "payload": payload})
if err != nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.clients {
select {
case ch <- msg:
default:
}
}
}
func (b *eventBroker) subscribe() chan []byte {
ch := make(chan []byte, 64)
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBroker) unsubscribe(ch chan []byte) {
b.mu.Lock()
delete(b.clients, ch)
close(ch)
b.mu.Unlock()
}
type bridgeApp struct {
service *api.Service
manager *sessions.Manager
store *storage.Store
secrets *secrets.SecretStore
events *eventBroker
}
func main() {
listenAddr := flag.String("listen", "127.0.0.1:0", "address for the localhost bridge")
flag.Parse()
bridge, err := newBridgeApp()
if err != nil {
log.Fatalf("initialise bridge: %v", err)
}
defer bridge.close()
token, err := randomToken()
if err != nil {
log.Fatalf("create auth token: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/health", bridge.auth(token, bridge.handleHealth))
mux.HandleFunc("/rpc", bridge.auth(token, bridge.handleRPC))
mux.HandleFunc("/events", bridge.auth(token, bridge.handleEvents))
mux.HandleFunc("/runtime/event", bridge.auth(token, bridge.handleRuntimeEvent))
server := newBridgeHTTPServer(mux)
listener, err := net.Listen("tcp", *listenAddr)
if err != nil {
log.Fatalf("listen: %v", err)
}
fmt.Fprintf(os.Stdout, "{\"addr\":%q,\"token\":%q}\n", listener.Addr().String(), token)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}
func newBridgeApp() (*bridgeApp, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
if err := cfg.EnsureDirectories(); err != nil {
return nil, err
}
store, err := storage.NewStore(cfg.DBPath)
if err != nil {
return nil, err
}
secretStore := secrets.NewSecretStore(cfg.SecretPath)
keychainStore := secrets.NewKeychainStore("AlchementaryLink")
auditLog := audit.NewLogger(store)
prefs := preferences.NewManager(store)
if err := prefs.Load(); err != nil {
log.Printf("warning: failed to load preferences: %v", err)
}
recordStore := recording.NewStore(filepath.Join(cfg.DataDir, "recordings"))
manager := sessions.NewManager(store, secretStore, keychainStore, auditLog, recordStore)
service := api.NewService(store, secretStore, keychainStore, manager, auditLog, prefs)
events := newEventBroker()
manager.SetEventSink(events.publish)
service.SetEventSink(events.publish)
return &bridgeApp{
service: service,
manager: manager,
store: store,
secrets: secretStore,
events: events,
}, nil
}
func (a *bridgeApp) close() {
if a.service != nil {
a.service.Shutdown()
}
if a.manager != nil {
a.manager.Shutdown()
}
if a.store != nil {
_ = a.store.Close()
}
if a.secrets != nil {
a.secrets.Lock()
}
}
func (a *bridgeApp) auth(token string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, "unauthorised", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func (a *bridgeApp) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"ok": true})
}
func (a *bridgeApp) handleRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req rpcRequest
if err := decodeLimitedJSON(w, r, &req); err != nil {
writeJSON(w, rpcResponse{Error: err.Error()})
return
}
result, err := callService(a.service, req.Method, req.Args)
if err != nil {
writeJSON(w, rpcResponse{Error: err.Error()})
return
}
writeJSON(w, rpcResponse{Result: result})
}
func (a *bridgeApp) handleRuntimeEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req runtimeEventRequest
if err := decodeLimitedJSON(w, r, &req); err != nil {
writeJSON(w, rpcResponse{Error: err.Error()})
return
}
switch req.Name {
case "session:secret:response":
requestID, _ := req.Payload["requestID"].(string)
value, _ := req.Payload["value"].(string)
cancelled, _ := req.Payload["cancelled"].(bool)
a.service.RespondSecret(requestID, value, cancelled)
a.manager.RespondSecret(requestID, value, cancelled)
case "session:hostkey:response":
requestID, _ := req.Payload["requestID"].(string)
accepted, _ := req.Payload["accepted"].(bool)
a.manager.RespondHostKey(requestID, accepted)
default:
writeJSON(w, rpcResponse{Error: "unsupported runtime event"})
return
}
writeJSON(w, rpcResponse{Result: true})
}
func (a *bridgeApp) handleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ch := a.events.subscribe()
defer a.events.unsubscribe(ch)
fmt.Fprint(w, ": connected\n\n")
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case msg := <-ch:
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
}
}
}
func callService(service *api.Service, methodName string, rawArgs []json.RawMessage) (any, error) {
if strings.TrimSpace(methodName) == "" {
return nil, errors.New("method is required")
}
method := reflect.ValueOf(service).MethodByName(methodName)
if !method.IsValid() {
return nil, fmt.Errorf("unknown service method %q", methodName)
}
mt := method.Type()
if len(rawArgs) != mt.NumIn() {
return nil, fmt.Errorf("%s expects %d args, got %d", methodName, mt.NumIn(), len(rawArgs))
}
args := make([]reflect.Value, mt.NumIn())
for i := 0; i < mt.NumIn(); i++ {
target := reflect.New(mt.In(i))
if err := json.Unmarshal(rawArgs[i], target.Interface()); err != nil {
return nil, fmt.Errorf("arg %d: %w", i, err)
}
args[i] = target.Elem()
}
values := method.Call(args)
if len(values) == 0 {
return true, nil
}
last := values[len(values)-1]
if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) && !last.IsNil() {
return nil, last.Interface().(error)
}
if len(values) == 1 {
if last.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
return true, nil
}
return last.Interface(), nil
}
return values[0].Interface(), nil
}
func randomToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
func newBridgeHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Handler: handler,
ReadHeaderTimeout: bridgeReadHeaderTimeout,
ReadTimeout: bridgeReadTimeout,
IdleTimeout: bridgeIdleTimeout,
MaxHeaderBytes: bridgeMaxHeaderBytes,
}
}
func decodeLimitedJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, bridgeMaxJSONBodyBytes)
return json.NewDecoder(r.Body).Decode(dst)
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
-66
View File
@@ -1,66 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewBridgeHTTPServerSetsResourceLimits(t *testing.T) {
server := newBridgeHTTPServer(http.NewServeMux())
if server.ReadHeaderTimeout != bridgeReadHeaderTimeout {
t.Fatalf("ReadHeaderTimeout = %s, want %s", server.ReadHeaderTimeout, bridgeReadHeaderTimeout)
}
if server.ReadTimeout != bridgeReadTimeout {
t.Fatalf("ReadTimeout = %s, want %s", server.ReadTimeout, bridgeReadTimeout)
}
if server.IdleTimeout != bridgeIdleTimeout {
t.Fatalf("IdleTimeout = %s, want %s", server.IdleTimeout, bridgeIdleTimeout)
}
if server.MaxHeaderBytes != bridgeMaxHeaderBytes {
t.Fatalf("MaxHeaderBytes = %d, want %d", server.MaxHeaderBytes, bridgeMaxHeaderBytes)
}
}
func TestHandleRPCRejectsOversizedJSONBody(t *testing.T) {
app := &bridgeApp{}
largeBody := `{"method":"ListProfiles","args":[],"pad":"` + strings.Repeat("x", bridgeMaxJSONBodyBytes) + `"}`
req := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(largeBody))
rec := httptest.NewRecorder()
app.handleRPC(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp rpcResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if !strings.Contains(resp.Error, "request body too large") {
t.Fatalf("error = %q, want request body limit error", resp.Error)
}
}
func TestHandleRuntimeEventRejectsOversizedJSONBody(t *testing.T) {
app := &bridgeApp{}
largeBody := `{"name":"session:secret:response","payload":{"pad":"` + strings.Repeat("x", bridgeMaxJSONBodyBytes) + `"}}`
req := httptest.NewRequest(http.MethodPost, "/runtime/event", strings.NewReader(largeBody))
rec := httptest.NewRecorder()
app.handleRuntimeEvent(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp rpcResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if !strings.Contains(resp.Error, "request body too large") {
t.Fatalf("error = %q, want request body limit error", resp.Error)
}
}
-13
View File
@@ -1,13 +0,0 @@
// Package frontend provides the embedded frontend assets for the Wails application.
package frontend
import "embed"
// Assets contains the frontend files served by the Wails asset server.
// We embed the root index.html and the entire src/ tree (stylesheets,
// scripts, vendor libs and static assets) so that `go build` works
// without a separate npm build step.
//
//go:embed index.html
//go:embed all:src
var Assets embed.FS
-352
View File
@@ -1,352 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Alchementary Link</title>
<!-- Vendor styles -->
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
<!-- Design system -->
<link rel="stylesheet" href="/src/css/tokens.css" />
<link rel="stylesheet" href="/src/css/base.css" />
<link rel="stylesheet" href="/src/css/components.css" />
<link rel="stylesheet" href="/src/css/layout.css" />
<link rel="stylesheet" href="/src/css/sidebar.css" />
<link rel="stylesheet" href="/src/css/tabs.css" />
<link rel="stylesheet" href="/src/css/terminal.css" />
<link rel="stylesheet" href="/src/css/splitpane.css" />
<link rel="stylesheet" href="/src/css/recordings.css" />
<link rel="stylesheet" href="/src/css/sftp.css" />
<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" />
<link rel="stylesheet" href="/src/css/irc.css" />
<link rel="stylesheet" href="/src/css/leviathan.css" />
</head>
<body>
<div id="app" class="app">
<!-- ============================================================
Sidebar — connection manager
============================================================ -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header wails-drag">
<div class="sidebar-logo">
<!-- Terminal prompt icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span class="sidebar-product-switch" aria-label="Application mode">
<button
class="sidebar-title sidebar-mode-toggle is-active"
id="mode-link"
type="button"
>
<span class="sidebar-brand-gold">Alchementary</span>Link
</button>
<button
class="sidebar-title sidebar-mode-toggle"
id="mode-leviathan"
type="button"
hidden
>
Leviathan
</button>
</span>
</div>
</div>
<div class="sidebar-search">
<div class="search-wrapper">
<input
type="text"
class="search-input"
id="profile-search"
placeholder="Search connections…"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<nav class="sidebar-nav" id="sidebar-nav">
<!-- Dynamically managed by sidebar.js -->
<div id="sidebar-profiles" class="sidebar-profiles"></div>
</nav>
<div class="sidebar-footer">
<button
class="btn btn-ghost btn-sm"
id="btn-new-connection"
title="New Connection"
>
<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"
title="Import Profiles"
>
<!-- Upload icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-export"
title="Export Profiles"
>
<!-- Download icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-recordings"
title="Session Recordings"
>
<!-- Film icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button
class="btn btn-ghost btn-icon btn-sm"
id="btn-settings"
title="Settings"
>
<!-- Cog icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0
1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0
0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0
1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6
9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0
1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65
0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2
2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0
0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
</div>
</aside>
<!-- ============================================================
Tab bar — session tabs
============================================================ -->
<div class="tab-bar" id="tab-bar">
<div class="tab-list" id="tab-list">
<!-- Tabs are injected dynamically by JS -->
</div>
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
<button
class="tab-broadcast"
id="btn-broadcast"
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
>
<!-- Broadcast icon (loudspeaker) -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
<!-- ============================================================
Workspace — terminal area
============================================================ -->
<main class="workspace" id="workspace">
<!-- Empty state — shown when no sessions are active -->
<div class="empty-state" id="empty-state">
<div class="empty-state-icon">
<!-- Terminal icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</div>
<h2>No active sessions</h2>
<p>
Select a connection from the sidebar or create a new one to get
started.
</p>
<div class="empty-state-actions">
<button class="btn btn-primary" id="btn-empty-connect">
New Connection
</button>
<button class="btn btn-secondary" id="btn-empty-quick">
Quick Connect
</button>
</div>
</div>
<!-- Session panels are injected here dynamically by tabs.js -->
<div id="panel-container" class="panel-container"></div>
</main>
<!-- ============================================================
Status bar
============================================================ -->
<footer class="status-bar" id="status-bar">
<span id="status-connection">
<span class="status-dot is-disconnected"></span>
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
class="status-update is-hidden"
id="status-update"
type="button"
>
Update available
</button>
<span id="status-vault">🔒 Locked</span>
</footer>
</div>
<!-- Modal container (dynamically populated) -->
<div id="modals"></div>
<!-- Notification toasts -->
<div id="notifications" class="notifications-container"></div>
<!-- Vendor libraries -->
<script src="/src/js/lib/xterm.min.js"></script>
<script src="/src/js/lib/xterm-addon-fit.min.js"></script>
<script src="/src/js/lib/xterm-addon-search.min.js"></script>
<script src="/src/js/lib/xterm-addon-unicode11.min.js"></script>
<script src="/src/js/lib/xterm-addon-web-links.min.js"></script>
<!-- Wails service bridge; loaded before application code. -->
<script src="/src/js/wails-service.js"></script>
<!-- Application scripts (loaded in dependency order) -->
<script src="/src/js/utils.js"></script>
<script src="/src/js/state.js"></script>
<script src="/src/js/notifications.js"></script>
<script src="/src/js/sidebar.js"></script>
<script src="/src/js/tabs.js"></script>
<script src="/src/js/themes.js"></script>
<script src="/src/js/terminal.js"></script>
<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/irc.js"></script>
<script src="/src/js/settings.js"></script>
<script src="/src/js/commands.js"></script>
<script src="/src/js/secrets.js"></script>
<script src="/src/js/recordings.js"></script>
<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/leviathan.js"></script>
<script src="/src/js/app.js"></script>
</body>
</html>
-547
View File
@@ -1,547 +0,0 @@
{
"name": "alchementary-link-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alchementary-link-frontend",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@novnc/novnc": "^1.5.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"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/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-search": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
{
"name": "alchementary-link-frontend",
"version": "0.1.0",
"private": true,
"description": "Frontend for Alchementary Link 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",
"test": "node scripts/test-session-lifecycle.js && node scripts/test-irc.js && node scripts/test-regressions.js",
"postinstall": "npm run install:assets && npm run bundle:novnc"
},
"dependencies": {
"@novnc/novnc": "^1.5.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
-1
View File
@@ -1 +0,0 @@
24ecf1ed6af3b41cf2f6d8fa0bc732ec
-233
View File
@@ -1,233 +0,0 @@
/**
* build.js — Build script for the Alchementary Link frontend.
*
* In production mode (no flags): copies vendor assets, assembles the
* dist/ directory with all files needed by the Wails embed, and validates
* that every required file is present.
*
* In watch mode (--watch): performs the same build, then watches for
* source changes and prints a notification when files change. Wails dev
* mode handles the actual live-reload; this script simply keeps the
* process alive so the frontend:dev:watcher contract is satisfied.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const frontendDir = path.resolve(__dirname, '..');
const distDir = path.join(frontendDir, 'dist');
const watchMode = process.argv.includes('--watch');
// Files that must exist after a successful build (relative to frontend/)
const requiredFiles = [
'dist/index.html',
'dist/src/css/lib/xterm.css',
'dist/src/js/lib/xterm.min.js',
'dist/src/js/lib/xterm-addon-fit.min.js',
'dist/src/js/lib/xterm-addon-search.min.js',
'dist/src/js/lib/xterm-addon-web-links.min.js',
'dist/src/js/wails-service.js',
];
/**
* Recursively copies a directory tree, creating parents as needed.
*/
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Copies one file and creates the parent directory if needed.
*/
function copyFileSync(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
/**
* Removes a directory tree if it exists.
*/
function cleanDir(dir) {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function copyWailsRuntimeBindings(srcRoot, destRoot) {
const runtimeFiles = [
'go/api/Service.js',
'runtime/runtime.js',
];
for (const relPath of runtimeFiles) {
const src = path.join(srcRoot, relPath);
if (fs.existsSync(src)) {
copyFileSync(src, path.join(destRoot, relPath));
}
}
}
function walkFiles(dir, predicate, files = []) {
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkFiles(full, predicate, files);
} else if (entry.isFile() && predicate(full)) {
files.push(full);
}
}
return files;
}
function cleanupDistAssets() {
console.log('[build] Cleaning production frontend assets...');
const sourceMapFiles = walkFiles(distDir, (file) => file.endsWith('.js') || file.endsWith('.css'));
let mapCommentCount = 0;
for (const file of sourceMapFiles) {
const input = fs.readFileSync(file, 'utf8');
const output = input
.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*?(?=\r?\n|$)/g, '')
.replace(/(?:\r?\n)?\/\*# sourceMappingURL=.*?\*\/(?=\r?\n|$)/g, '');
if (output !== input) {
fs.writeFileSync(file, output);
mapCommentCount += 1;
}
}
if (mapCommentCount > 0) {
console.log(`[build] Removed ${mapCommentCount} source-map reference(s).`);
}
}
/**
* Runs the asset copy script to pull xterm files into src/js/lib/ and
* src/css/lib/.
*/
function copyAssets() {
console.log('[build] Copying vendor library assets…');
execSync('node scripts/copy-assets.js', {
cwd: frontendDir,
stdio: 'inherit',
});
execSync('node scripts/bundle-novnc.js', {
cwd: frontendDir,
stdio: 'inherit',
});
}
/**
* Assembles the dist/ directory with everything the Wails embed needs:
* dist/index.html
* dist/src/css/ (application + lib stylesheets)
* dist/src/js/ (application + lib scripts)
* dist/wailsjs/ (generated backend bindings, if present)
* dist/src/components/ (if present)
* dist/src/assets/ (if present)
*/
function buildDist() {
console.log('[build] Assembling dist/ directory…');
// Start fresh so stale files are removed
cleanDir(distDir);
fs.mkdirSync(distDir, { recursive: true });
// Copy the root index.html
fs.copyFileSync(
path.join(frontendDir, 'index.html'),
path.join(distDir, 'index.html'),
);
// Copy the entire src/ tree (CSS, JS, components, assets, lib files)
const srcDir = path.join(frontendDir, 'src');
if (fs.existsSync(srcDir)) {
copyDirSync(srcDir, path.join(distDir, 'src'));
}
// Copy only runtime Wails bindings so embedded production assets do not
// carry TypeScript declarations and model files.
const wailsjsDir = path.join(frontendDir, 'wailsjs');
if (fs.existsSync(wailsjsDir)) {
copyWailsRuntimeBindings(wailsjsDir, path.join(distDir, 'wailsjs'));
}
console.log('[build] dist/ assembled.');
}
/**
* Validates that every required file exists.
* Returns true if all files are present.
*/
function validate() {
console.log('[build] Validating required files…');
let valid = true;
for (const relPath of requiredFiles) {
const full = path.join(frontendDir, relPath);
if (!fs.existsSync(full)) {
console.error(` ✗ Missing: ${relPath}`);
valid = false;
} else {
console.log(` ✓ ${relPath}`);
}
}
return valid;
}
/**
* Watches the src/ directory for changes (development mode).
* Wails dev mode picks up the changes; we just keep the process alive.
*/
function watch() {
const srcDir = path.join(frontendDir, 'src');
console.log(`[build] Watching ${srcDir} for changes…`);
// Use fs.watch recursively where supported (macOS, Windows).
// On Linux this falls back to non-recursive — acceptable for dev use.
try {
fs.watch(srcDir, { recursive: true }, (_event, filename) => {
if (filename) {
const stamp = new Date().toLocaleTimeString('en-GB');
console.log(`[build] ${stamp} — changed: ${filename}`);
}
});
} catch (err) {
console.warn('[build] Recursive watch not supported; polling fallback.');
}
// Prevent the process from exiting
setInterval(() => {}, 60_000);
}
// --- Main ---
try {
copyAssets();
} catch (err) {
console.error('[build] Asset copy failed:', err.message);
process.exit(1);
}
buildDist();
cleanupDistAssets();
if (!validate()) {
console.error('[build] Validation failed — some required files are missing.');
process.exit(1);
}
console.log('[build] Frontend build complete.');
if (watchMode) {
watch();
}
-43
View File
@@ -1,43 +0,0 @@
/**
* 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);
});
-87
View File
@@ -1,87 +0,0 @@
/**
* copy-assets.js — Copies xterm library assets from node_modules into
* the frontend source tree so they can be served directly by Wails.
*
* Run automatically via the postinstall hook in package.json.
*/
"use strict";
const fs = require("fs");
const path = require("path");
const frontendDir = path.resolve(__dirname, "..");
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
const assets = [
{
src: "@xterm/xterm/css/xterm.css",
dest: "src/css/lib/xterm.css",
},
{
src: "@xterm/xterm/lib/xterm.js",
dest: "src/js/lib/xterm.min.js",
},
{
src: "@xterm/addon-fit/lib/addon-fit.js",
dest: "src/js/lib/xterm-addon-fit.min.js",
},
{
src: "@xterm/addon-search/lib/addon-search.js",
dest: "src/js/lib/xterm-addon-search.min.js",
},
{
src: "@xterm/addon-unicode11/lib/addon-unicode11.js",
dest: "src/js/lib/xterm-addon-unicode11.min.js",
},
{
src: "@xterm/addon-web-links/lib/addon-web-links.js",
dest: "src/js/lib/xterm-addon-web-links.min.js",
},
];
/**
* Ensures the parent directory of the given file path exists.
*/
function ensureDir(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Copies a single asset, logging the result.
*/
function copyAsset(entry) {
const srcPath = path.join(frontendDir, "node_modules", entry.src);
const destPath = path.join(frontendDir, entry.dest);
if (!fs.existsSync(srcPath)) {
console.error(` ✗ Source not found: ${entry.src}`);
return false;
}
ensureDir(destPath);
fs.copyFileSync(srcPath, destPath);
console.log(` ✓ ${entry.src} → ${entry.dest}`);
return true;
}
// --- Main ---
console.log("[copy-assets] Copying xterm library files…");
let failures = 0;
for (const entry of assets) {
if (!copyAsset(entry)) {
failures++;
}
}
if (failures > 0) {
console.error(`\n[copy-assets] ${failures} file(s) could not be copied.`);
console.error('[copy-assets] Have you run "npm install" first?');
process.exit(1);
}
console.log("[copy-assets] All assets copied successfully.");
-7
View File
@@ -1,7 +0,0 @@
// 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";
import { initLogging } from "../node_modules/@novnc/novnc/core/util/logging.js";
initLogging("error");
window.noVNC = { RFB: RFB };
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const frontendRoot = path.resolve(__dirname, "..");
const source = fs.readFileSync(path.join(frontendRoot, "src/js/irc.js"), "utf8");
const context = {
window: {
AlchementaryLink: {
createElement: () => {
throw new Error("formatting tests must not create DOM elements");
},
},
},
};
vm.createContext(context);
vm.runInContext(source, context, { filename: "src/js/irc.js" });
const formatting = context.window.AlchementaryLink.IRCFormatting;
assert.ok(formatting, "IRC formatting helpers should be exported");
assert.strictEqual(
formatting.formatText("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"),
"plain \x1b[1mbold\x1b[22m \x1b[3mitalic\x1b[23m \x1b[4munderlined\x1b[24m",
);
assert.strictEqual(
formatting.formatText("\x0304red\x03 default"),
"\x1b[38;5;9mred\x1b[39;49m default",
);
assert.strictEqual(
formatting.formatText("\x0352extended \x0303,01matrix"),
"\x1b[38;5;196mextended \x1b[38;5;2m\x1b[48;5;0mmatrix",
);
assert.strictEqual(
formatting.formatText("\x04ff8800orange\x04 default"),
"\x1b[38;2;255;136;0morange\x1b[39;49m default",
);
assert.strictEqual(
formatting.formatText("\x0304,comma"),
"\x1b[38;5;9m,comma",
"a colour code without a background must leave its comma visible",
);
assert.strictEqual(
formatting.plainText("\x02bold\x02 \x0304red\x03"),
"bold red",
);
assert.strictEqual(
formatting.formatText("before\x1b[31munsafe\x1b]0;title\x07after"),
"beforeunsafeafter",
"untrusted ANSI and OSC sequences must not reach xterm",
);
const message = formatting.formatEvent(
{
type: "message",
from: "alice",
text: "hello \x02world\x02",
},
"12:34:56",
);
assert.ok(message.includes("[12:34:56]"));
assert.ok(message.includes("\x1b[1mworld\x1b[22m"));
assert.ok(message.includes("\x1b[1m\x1b[38;5;"), "nicknames should be bold and coloured");
assert.ok(message.endsWith("\x1b[0m"), "every line must reset terminal styling");
const action = formatting.formatEvent(
{ type: "action", from: "bob", text: "waves" },
"01:02:03",
);
assert.ok(action.includes("\x1b[3m"), "actions should be italicised");
console.log("IRC formatting regression tests passed.");
-575
View File
@@ -1,575 +0,0 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const repo = path.resolve(root, "..");
function read(rel) {
return fs.readFileSync(path.join(repo, rel), "utf8");
}
function assertContains(file, needle, message) {
const text = read(file);
if (!text.includes(needle)) {
throw new Error(`${message}\nMissing ${JSON.stringify(needle)} in ${file}`);
}
}
function assertNotContains(file, needle, message) {
const text = read(file);
if (text.includes(needle)) {
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${file}`);
}
}
function assertBefore(file, first, second, message) {
const text = read(file);
const firstIndex = text.indexOf(first);
const secondIndex = text.indexOf(second);
if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) {
throw new Error(
`${message}\nExpected ${JSON.stringify(first)} before ${JSON.stringify(second)} in ${file}`,
);
}
}
function assertTreeNotContains(relDir, needle, message) {
const dir = path.join(repo, relDir);
const offenders = [];
const walk = (current) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".js")) continue;
const rel = path.relative(repo, full);
if (read(rel).includes(needle)) offenders.push(rel);
}
};
walk(dir);
if (offenders.length) {
throw new Error(`${message}\nUnexpected ${JSON.stringify(needle)} in ${offenders.join(", ")}`);
}
}
assertContains(
"frontend/src/js/terminal.js",
"escapeHtml(title)",
"file-drop prompt titles must escape filenames containing HTML characters",
);
assertContains(
"frontend/src/js/terminal.js",
"SFTPUploadStart",
"drag-drop upload must use chunked SFTP streaming",
);
assertContains(
"frontend/src/js/terminal.js",
"SFTPUploadChunk",
"drag-drop upload must send bounded chunks",
);
assertContains(
"frontend/src/js/terminal.js",
"scheduleFit",
"terminal resize handling must be throttled through scheduled fits",
);
assertContains(
"frontend/src/js/terminal.js",
"dimensionsChanged",
"terminal resize handling must skip unchanged container dimensions",
);
assertContains(
"frontend/src/js/terminal.js",
"cancelScheduledFit",
"terminal teardown must cancel pending fit work",
);
assertContains(
"frontend/src/js/terminal.js",
"this._sendTerminalSize(sessionId, term);",
"every completed terminal fit must publish its geometry to the backend PTY",
);
assertContains(
"frontend/src/js/terminal.js",
"[Exited - press enter to restart]",
"exited local shells must show the inline restart prompt",
);
assertContains(
"frontend/src/js/terminal.js",
"RestartLocalSession",
"pressing enter after a local shell exit must restart the existing session",
);
assertContains(
"frontend/src/js/app.js",
"!localExited &&",
"local shell exits must not show the disconnected overlay",
);
assertNotContains(
"frontend/src/js/terminal.js",
"new ResizeObserver(() => {\n try {\n fitAddon.fit();",
"ResizeObserver must not call fitAddon.fit() directly",
);
assertNotContains(
"frontend/src/js/terminal.js",
"Use SFTP Upload for files over 25 MB",
"drag-drop upload must not reject files only because they exceed the old inline limit",
);
assertContains(
"frontend/src/js/app.js",
"confirmDirectVNCProfile",
"direct VNC connections must show an explicit warning",
);
assertContains(
"frontend/src/js/profiles.js",
"skipDirectVNCWarning",
"save-and-connect should not show duplicate direct VNC prompts",
);
assertContains(
"frontend/src/js/recordings.js",
"import-status",
"profile import failures must be visible inside the import modal",
);
assertContains(
"frontend/src/js/recordings.js",
"_formatImportError",
"profile import failures must be formatted without using HTML injection",
);
assertContains(
"internal/api/api_test.go",
"TestCreateProfileRejectsUnsafeGitHubBaseURL",
"GitHub HTTP/HTTPS URL validation must stay covered",
);
assertContains(
"frontend/index.html",
"mode-leviathan",
"Leviathan mode toggle must be present beside Alchementary Link",
);
assertContains(
"frontend/index.html",
'id="mode-leviathan"\n type="button"\n hidden',
"Leviathan mode toggle must be hidden before preferences load",
);
assertContains(
"frontend/src/js/app.js",
"prefs.leviathanEnabled === true",
"Leviathan mode must only be exposed by an explicit persisted preference",
);
assertContains(
"frontend/src/js/settings.js",
"KONAMI_SEQUENCE",
"the About window must recognise the Konami sequence",
);
assertContains(
"frontend/src/js/settings.js",
"_toggleLeviathanFromKonami",
"the About-window Konami sequence must toggle Leviathan",
);
assertContains(
"frontend/src/js/settings.js",
"SavePreferences(prefs)",
"the Konami toggle must persist the Leviathan preference immediately",
);
assertContains(
"frontend/src/css/sidebar.css",
"user-select: none",
"sidebar title and mode switcher text must not be selectable",
);
assertContains(
"frontend/src/css/sidebar.css",
"#mode-leviathan",
"Leviathan mode label must have its own right-alignment rule",
);
assertContains(
"frontend/src/css/sidebar.css",
"margin-left: auto;",
"Leviathan mode label must sit on the right side of the sidebar header",
);
assertContains(
"frontend/src/css/tokens.css",
"--tab-bar-height: 44px;",
"tab bar row must match the sidebar header height",
);
assertContains(
"frontend/src/css/sidebar.css",
"height: var(--tab-bar-height);",
"sidebar header must use the shared tab bar height",
);
assertContains(
"frontend/src/css/layout.css",
"height: var(--tab-bar-height);",
"tab bar must use the shared sidebar header height",
);
assertContains(
"frontend/src/js/tabs.js",
"this._setupScrolling();",
"tab bar must enable horizontal scroll handling for overflowed tabs",
);
assertContains(
"frontend/src/js/tabs.js",
"this._setupTooltip();",
"tab bar must show rich hover hints for truncated tabs",
);
assertContains(
"frontend/src/js/tabs.js",
"removeTab(sessionId) {\n this._hideTabTooltip();",
"removing a tab must clear any visible tab tooltip",
);
assertContains(
"frontend/src/js/tabs.js",
"async closeTab(sessionId) {\n this._hideTabTooltip();",
"closing a tab must clear any visible or pending tab tooltip",
);
assertContains(
"frontend/src/js/tabs.js",
"await this.app.closeSessionImmediate(sessionId);",
"all normal tab closes must use the complete session teardown path",
);
assertContains(
"frontend/src/js/app.js",
"this.splitPanes.cleanup(sessionId)",
"closing an owner tab must release all split-pane state and child sessions",
);
assertContains(
"frontend/src/js/app.js",
"window.AlchementaryLink.VNCViewer?.destroyViewer(sessionId)",
"session teardown must disconnect and remove VNC viewers",
);
assertContains(
"frontend/src/js/app.js",
"window.AlchementaryLink.IRCViewer?.destroyViewer(sessionId)",
"session teardown must dispose IRC terminals and resize observers",
);
assertContains(
"frontend/src/js/irc.js",
"wailsService.IRCRooms(this.sessionID)",
"IRC viewers created after connection must hydrate the already joined rooms",
);
assertContains(
"internal/sessions/manager.go",
"GetIRCLastRooms(profile.ID)",
"saved IRC profiles must restore the channels open in their last session",
);
assertContains(
"frontend/src/js/app.js",
"this.recordings.cleanup(sessionId)",
"session teardown must clear frontend recording state",
);
assertContains(
"frontend/src/js/splitpane.js",
"this.app.closeSessionImmediate(leaf.sessionId",
"closing a split child must release its backend session as well as its terminal",
);
assertContains(
"frontend/src/js/splitpane.js",
"await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id)",
"late split-pane connection results must be disconnected after their pane closes",
);
assertContains(
"internal/sessions/manager.go",
"delete(m.sessions, sessionID)",
"explicit backend disconnect must remove the session from the manager map",
);
assertContains(
"frontend/src/js/tabs.js",
"!tab?.isConnected",
"tab tooltip timers must not show hints for removed tab elements",
);
assertContains(
"frontend/src/js/tabs.js",
"_clearTabTooltipTimer",
"tab tooltip cleanup must cancel pending hover timers",
);
assertContains(
"frontend/src/js/tabs.js",
"session.protocol !== \"leviathan\"",
"Leviathan tabs must close without connected-session confirmation",
);
assertContains(
"frontend/src/js/tabs.js",
"session.protocol !== \"github\"",
"GitHub tabs must close without connected-session confirmation",
);
assertContains(
"frontend/src/js/tabs.js",
"Connected since:",
"connection tab hover hints must include the connected-since timestamp",
);
assertContains(
"frontend/src/js/tabs.js",
"tab.appendChild(closeBtn);\n\n // Protocol badge",
"tab close button must render on the left side of each tab",
);
assertContains(
"frontend/src/css/layout.css",
"scrollbar-gutter: stable;",
"overflowing tab lists must expose a stable horizontal scrollbar",
);
assertContains(
"frontend/src/css/tabs.css",
".tab-tooltip",
"tab hover hints must have dedicated tooltip styling",
);
assertContains(
"frontend/src/js/leviathan.js",
"Hex Editor",
"Leviathan mode must put Hex Editor in the tool list",
);
assertContains(
"frontend/src/js/settings.js",
"Leviathan Binary Tools",
"in-app help must document the integrated Leviathan workbench",
);
assertContains(
"frontend/src/js/settings.js",
"VNC Connections",
"in-app help must document VNC connection profiles",
);
assertContains(
"docs/user-manual.md",
"## 11. Leviathan binary workbench",
"user manual must include a full Leviathan section",
);
assertContains(
"docs/user-manual.md",
"### VNC",
"user manual must include VNC connection guidance",
);
assertContains(
"docs/overview.md",
"### Leviathan binary workbench",
"product overview must include Leviathan as a first-class feature group",
);
assertContains(
"frontend/src/js/leviathan.js",
"LeviathanSetByte",
"Leviathan hex view must support byte editing",
);
assertContains(
"frontend/src/js/leviathan.js",
"_handleHexByteInput",
"Leviathan hex byte edits must update while typing",
);
assertContains(
"frontend/src/js/leviathan.js",
"_updateHexAsciiCell",
"Leviathan hex byte edits must update the ASCII preview immediately",
);
assertContains(
"frontend/src/js/leviathan.js",
"_focusHexByte(cell, 1)",
"Leviathan hex byte edits must advance to the next byte after entry",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-encode",
"Leviathan must expose an image-to-audio spectrogram encoder",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-view",
"Leviathan must expose an audio spectrogram visualiser",
);
assertContains(
"frontend/src/js/leviathan.js",
"preserveAspectRatio",
"Leviathan spectrogram encoding must expose aspect-ratio preservation",
);
assertContains(
"frontend/src/js/leviathan.js",
"aspectRatio",
"Leviathan spectrogram visualiser must expose display aspect-ratio control",
);
assertContains(
"frontend/src/js/leviathan.js",
"spectrogram-insert",
"Leviathan spectrogram encoder must support inserting images into carrier audio",
);
assertContains(
"frontend/src/js/app.js",
"setMode(\"leviathan\")",
"App mode switch must route to Leviathan",
);
assertBefore(
"frontend/src/js/splitpane.js",
"localLabel.textContent = 'Local Machine';",
"profiles.forEach(function(profile)",
"split-pane connection choices must put Local Machine before saved profiles",
);
assertContains(
"frontend/src/js/splitpane.js",
"wailsService.ConnectLocal()",
"the Local Machine split-pane choice must open a real local shell",
);
assertContains(
"internal/api/leviathan.go",
"LeviathanRunTool",
"Link API must expose the imported Leviathan engine",
);
assertNotContains(
"internal/api/leviathan.go",
"Pattern: \"*\"",
"Leviathan open dialogs must not use wildcard filters that grey out files on macOS",
);
assertContains(
"internal/leviathan/spectrogram/spectrogram.go",
"github.com/hajimehoshi/go-mp3",
"Leviathan spectrogram visualiser must support MP3 decoding",
);
assertNotContains(
"wails.json",
"\"obfuscated\": true",
"Wails obfuscation must stay disabled because this app embeds frontend/src and uses normal Wails globals",
);
assertNotContains(
"wails.json",
"\"garbleargs\"",
"garble arguments must not be configured when Wails obfuscation is disabled",
);
assertContains(
"frontend/index.html",
"wails-service.js",
"frontend must load the Wails service bridge before app code",
);
assertNotContains(
"frontend/index.html",
'type="module" src="/src/js/wails-service.js"',
"Wails bridge must load as a synchronous classic script before app code",
);
assertContains(
"frontend/src/js/wails-service.js",
'prop === "then"',
"Wails service proxy must not masquerade as a Promise thenable",
);
assertContains(
"frontend/src/js/wails-service.js",
"window.go?.api?.Service",
"Wails service bridge must use the normal named Wails API",
);
assertContains(
"frontend/src/js/sshkeys.js",
"textContent: \"Failed to load keys: \" + String(err)",
"SSH key manager errors must be rendered as text rather than injected HTML",
);
assertContains(
"frontend/src/js/sshkeys.js",
"textContent: message",
"SSH key delete confirmation messages must be rendered as text",
);
assertNotContains(
"frontend/src/js/sshkeys.js",
"Failed to load keys: ${err}",
"SSH key manager must not interpolate backend errors into innerHTML",
);
assertContains(
"frontend/package.json",
'"@xterm/xterm":',
"frontend dependencies must use the maintained scoped xterm package",
);
assertContains(
"frontend/package.json",
'"@xterm/addon-fit":',
"frontend dependencies must use the maintained scoped xterm addons",
);
assertContains(
"frontend/scripts/copy-assets.js",
"@xterm/xterm/lib/xterm.js",
"xterm browser assets must be copied from the maintained scoped package",
);
assertContains(
"frontend/scripts/copy-assets.js",
"@xterm/addon-fit/lib/addon-fit.js",
"xterm addon browser assets must be copied from the maintained scoped packages",
);
assertNotContains(
"frontend/package.json",
'"xterm":',
"deprecated unscoped xterm dependency must not be restored",
);
assertNotContains(
"frontend/package.json",
'"xterm-addon-',
"deprecated unscoped xterm addon dependencies must not be restored",
);
assertNotContains(
"frontend/package-lock.json",
'"node_modules/xterm"',
"package lock must not contain the deprecated unscoped xterm package",
);
assertNotContains(
"frontend/package-lock.json",
'"node_modules/xterm-addon-',
"package lock must not contain deprecated unscoped xterm addon packages",
);
assertContains(
"frontend/scripts/build.js",
"sourceMappingURL",
"frontend production build must remove source-map references",
);
assertContains(
"frontend/scripts/build.js",
"bundle-novnc.js",
"frontend production build must regenerate the noVNC bundle",
);
assertContains(
"frontend/scripts/novnc-entry.js",
"initLogging(\"error\")",
"bundled noVNC logging must default to errors only",
);
assertNotContains(
"frontend/src/js/app.js",
"console.debug(\"[session:debug]\"",
"release UI must not print telnet negotiation debug events",
);
assertNotContains(
"frontend/src/js/app.js",
"console.log(\"Backend is ready\")",
"release UI must not print backend-ready lifecycle events",
);
assertContains(
"build.sh",
"LINK_COMPRESS",
"local builds must expose an explicit compression switch",
);
assertContains(
"scripts/build-common.sh",
"link_use_upx_for_target",
"build scripts must share one UPX target-selection helper",
);
assertContains(
"build.sh",
"Skipping UPX compression on macOS",
"local compressed builds must avoid unsupported macOS UPX by default",
);
assertContains(
"build.sh",
"-trimpath",
"local production builds should trim file paths from binaries",
);
assertContains(
"scripts/build-common.sh",
"-buildid=",
"production builds must strip the Go build ID",
);
assertContains(
"scripts/build-common.sh",
"CGO_ENABLED=0",
"production builds must keep the pure-Go SQLite toolchain",
);
assertContains(
"Makefile",
"build-compressed",
"Makefile must expose an explicit compressed build target",
);
assertContains(
"Makefile",
"LINK_FORCE_MACOS_UPX",
"Makefile compressed builds must make macOS UPX opt-in",
);
assertTreeNotContains(
"frontend/src/js",
"window.go.api.Service",
"app code must not call named Wails backend methods directly",
);
console.log("frontend regression checks passed");
-305
View File
@@ -1,305 +0,0 @@
#!/usr/bin/env node
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const frontendRoot = path.resolve(__dirname, "..");
function loadScript(relativePath, additions = {}) {
const context = {
console,
Promise,
Map,
Set,
setTimeout,
clearTimeout,
window: { AlchementaryLink: {} },
document: {
querySelector: () => null,
addEventListener: () => {},
},
...additions,
};
vm.createContext(context);
vm.runInContext(
fs.readFileSync(path.join(frontendRoot, relativePath), "utf8"),
context,
{ filename: relativePath },
);
return context;
}
async function testRepeatedSplitCleanup() {
const context = loadScript("src/js/splitpane.js");
const SplitPaneManager = context.window.AlchementaryLink.SplitPaneManager;
const closed = [];
const app = {
closeSessionImmediate: async (sessionId, options) => {
closed.push({ sessionId, options });
},
};
const manager = new SplitPaneManager(app);
let cancelledDrags = 0;
for (let i = 0; i < 500; i++) {
const ownerId = `owner-${i}`;
const childId = `child-${i}`;
const ownerEl = { _splitNode: {} };
const childEl = { _splitNode: {} };
const ownerLeaf = {
type: "leaf",
paneId: `owner-pane-${i}`,
el: ownerEl,
sessionId: ownerId,
parent: null,
};
const childLeaf = {
type: "leaf",
paneId: `child-pane-${i}`,
el: childEl,
sessionId: childId,
parent: null,
};
const branchEl = { _splitNode: {} };
const branch = {
type: "branch",
first: ownerLeaf,
second: childLeaf,
el: branchEl,
parent: null,
cancelDrag: () => {
cancelledDrags++;
},
};
ownerLeaf.parent = branch;
childLeaf.parent = branch;
manager.trees.set(ownerId, branch);
manager.pendingConnections.set(`pending-${i}`, {
ownerSessionId: ownerId,
cancelled: false,
completed: false,
});
await manager.cleanup(ownerId);
assert.strictEqual(ownerEl._splitNode, null);
assert.strictEqual(childEl._splitNode, null);
assert.strictEqual(branchEl._splitNode, null);
}
assert.strictEqual(manager.trees.size, 0);
assert.strictEqual(manager.pendingConnections.size, 0);
assert.strictEqual(closed.length, 500);
assert.strictEqual(cancelledDrags, 500);
for (const call of closed) {
assert.strictEqual(call.options.removeTab, false);
assert.strictEqual(call.options.cleanupSplits, false);
}
}
function testClosedPaneRejectsLateConnection() {
const context = loadScript("src/js/splitpane.js");
const SplitPaneManager = context.window.AlchementaryLink.SplitPaneManager;
const manager = new SplitPaneManager({ closeSessionImmediate: async () => {} });
const pane = {
dataset: { paneId: "pane-1" },
isConnected: true,
};
const leaf = {
type: "leaf",
paneId: "pane-1",
el: pane,
sessionId: null,
parent: null,
};
pane._splitNode = leaf;
manager.trees.set("owner-1", leaf);
const token = manager._beginPendingConnection(pane, "owner-1");
manager._cancelPendingConnection("pane-1");
assert.strictEqual(
manager._completePendingConnection(pane, "owner-1", token),
false,
);
assert.strictEqual(manager.pendingConnections.size, 0);
}
function testPerSessionStoresReleaseIDs() {
const stateContext = loadScript("src/js/state.js");
const State = stateContext.window.AlchementaryLink.State;
const state = new State();
state.addSession({ id: "session-1" });
state.broadcastTargets = new Set(["session-1"]);
state.removeSession("session-1");
assert.strictEqual(state.sessions.size, 0);
assert.strictEqual(state.broadcastTargets.size, 0);
const recordingContext = loadScript("src/js/recordings.js");
const RecordingManager =
recordingContext.window.AlchementaryLink.RecordingManager;
const recordings = new RecordingManager({});
recordings.activeSessions.add("session-1");
recordings.cleanup("session-1");
assert.strictEqual(recordings.activeSessions.size, 0);
}
async function testCompleteAppTeardownIsImmediateAndIdempotent() {
let finishBackendClose;
let backendCloses = 0;
const context = loadScript("src/js/app.js");
const App = context.window.AlchementaryLink.App;
const app = Object.create(App.prototype);
const resources = {
sftp: new Set(["session-1"]),
triggers: new Set(["session-1"]),
recordings: new Set(["session-1"]),
terminals: new Set(["session-1"]),
raw: new Set(["session-1"]),
github: new Set(["session-1"]),
vnc: new Set(["session-1"]),
irc: new Set(["session-1"]),
leviathan: new Set(["session-1"]),
};
const drop = (name) => ({
cleanup: (id) => resources[name].delete(id),
close: (id) => resources[name].delete(id),
destroyTerminal: (id) => resources[name].delete(id),
destroyViewer: (id) => resources[name].delete(id),
destroyPanel: (id) => resources[name].delete(id),
});
app._closingSessions = new Map();
app.splitPanes = { cleanup: async () => {} };
app.sftp = drop("sftp");
app.triggers = drop("triggers");
app.recordings = drop("recordings");
app.terminals = drop("terminals");
app.rawViewer = drop("raw");
app.leviathan = drop("leviathan");
app.tabs = {
order: ["session-1"],
removeTab: (id) => {
app.tabs.order = app.tabs.order.filter((value) => value !== id);
},
};
app.state = {
sessions: new Map([["session-1", { id: "session-1" }]]),
getSession(id) {
return this.sessions.get(id);
},
removeSession(id) {
this.sessions.delete(id);
},
};
app.updateStatusBar = () => {};
context.window.AlchementaryLink.GitHubViewer = drop("github");
context.window.AlchementaryLink.VNCViewer = drop("vnc");
context.window.AlchementaryLink.IRCViewer = drop("irc");
context.window.AlchementaryLink.wailsService = {
DisconnectSession: () => {
backendCloses++;
return new Promise((resolve) => {
finishBackendClose = resolve;
});
},
};
const first = app.closeSessionImmediate("session-1");
const second = app.closeSessionImmediate("session-1");
assert.strictEqual(first, second);
assert.strictEqual(backendCloses, 1);
assert.strictEqual(app.tabs.order.length, 0);
assert.strictEqual(app.state.sessions.size, 0);
for (const retained of Object.values(resources)) {
assert.strictEqual(retained.size, 0);
}
finishBackendClose();
await first;
assert.strictEqual(app._closingSessions.size, 0);
}
async function testLatencyPingIsSingleFlightAndIgnoresStaleResults() {
const flushAsync = () => new Promise((resolve) => setImmediate(resolve));
let nextTimerID = 1;
const timers = new Map();
const latencyEl = { textContent: "", className: "" };
const context = loadScript("src/js/app.js", {
setTimeout: (callback) => {
const id = nextTimerID++;
timers.set(id, callback);
return id;
},
clearTimeout: (id) => timers.delete(id),
document: {
querySelector: () => null,
getElementById: (id) => (id === "status-latency" ? latencyEl : null),
addEventListener: () => {},
},
});
const App = context.window.AlchementaryLink.App;
const app = Object.create(App.prototype);
app._latencySessionID = null;
app._latencyTimer = null;
app._latencyGeneration = 0;
app._latencyPingInFlight = false;
const pending = [];
context.window.AlchementaryLink.wailsService = {
PingSession: (sessionID) =>
new Promise((resolve, reject) => {
pending.push({ sessionID, resolve, reject });
}),
};
app._startLatencyPing("session-a");
app._startLatencyPing("session-a");
assert.strictEqual(pending.length, 1, "an in-flight ping must not overlap");
assert.strictEqual(timers.size, 0, "the next ping waits for completion");
app._startLatencyPing("session-b");
assert.strictEqual(pending.length, 2);
pending[0].reject(new Error("stale session closed"));
await flushAsync();
assert.strictEqual(app._latencySessionID, "session-b");
assert.strictEqual(
app._latencyPingInFlight,
true,
"a stale rejection must not stop the current session ping",
);
pending[1].resolve(42);
await flushAsync();
assert.strictEqual(latencyEl.textContent, "42 ms");
assert.strictEqual(latencyEl.className, "latency-good");
assert.strictEqual(timers.size, 1, "one follow-up ping should be scheduled");
const followUp = timers.values().next().value;
timers.clear();
followUp();
assert.strictEqual(pending.length, 3);
assert.strictEqual(pending[2].sessionID, "session-b");
assert.strictEqual(timers.size, 0, "follow-up pings must also be single-flight");
pending[2].resolve(250);
await flushAsync();
assert.strictEqual(latencyEl.className, "latency-bad");
assert.strictEqual(timers.size, 1);
app._stopLatencyPing();
assert.strictEqual(timers.size, 0);
}
(async () => {
await testRepeatedSplitCleanup();
testClosedPaneRejectsLateConnection();
testPerSessionStoresReleaseIDs();
await testCompleteAppTeardownIsImmediateAndIdempotent();
await testLatencyPingIsSingleFlightAndIgnoresStaleResults();
console.log("Session lifecycle regression tests passed.");
})().catch((err) => {
console.error(err);
process.exitCode = 1;
});
-187
View File
@@ -1,187 +0,0 @@
/* ==========================================================================
Alchementary Link — Base / Reset
Modern CSS reset and foundational element styles.
========================================================================== */
/* -----------------------------------------------------------------------
Box-model reset
----------------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* -----------------------------------------------------------------------
Root element
----------------------------------------------------------------------- */
html,
body {
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--text-primary);
background: var(--bg-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
}
/* -----------------------------------------------------------------------
Scrollbar styling (Webkit / Blink)
----------------------------------------------------------------------- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
/* -----------------------------------------------------------------------
Text selection
----------------------------------------------------------------------- */
::selection {
background: var(--accent-strong);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Focus management
----------------------------------------------------------------------- */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* -----------------------------------------------------------------------
Links
----------------------------------------------------------------------- */
a {
color: var(--accent);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--accent-hover);
}
/* -----------------------------------------------------------------------
Images
----------------------------------------------------------------------- */
img,
svg {
display: block;
max-width: 100%;
}
/* -----------------------------------------------------------------------
Lists
----------------------------------------------------------------------- */
ul,
ol {
list-style: none;
}
/* -----------------------------------------------------------------------
Tables
----------------------------------------------------------------------- */
table {
border-collapse: collapse;
border-spacing: 0;
}
/* -----------------------------------------------------------------------
Buttons & inputs — inherit font
----------------------------------------------------------------------- */
button,
input,
select,
textarea {
font: inherit;
color: inherit;
background: none;
border: none;
}
button {
cursor: pointer;
}
/* -----------------------------------------------------------------------
Monospace elements
----------------------------------------------------------------------- */
code,
pre,
kbd,
.mono {
font-family: var(--font-mono);
}
/* -----------------------------------------------------------------------
Hidden utility
----------------------------------------------------------------------- */
[hidden],
.hidden {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* -----------------------------------------------------------------------
Text utilities
----------------------------------------------------------------------- */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-accent { color: var(--accent); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-error { color: var(--error); }
/* -----------------------------------------------------------------------
Wails window drag region
----------------------------------------------------------------------- */
.wails-drag {
--wails-draggable: drag;
}
File diff suppressed because it is too large Load Diff
-745
View File
@@ -1,745 +0,0 @@
/* ==========================================================================
Alchementary Link — Profile Editor & Form Polish
Section headers, grouped fields, protocol-specific visibility,
inline validation, tag input, jump host list, key file selector.
========================================================================== */
/* -----------------------------------------------------------------------
Form panel (profile editor container)
----------------------------------------------------------------------- */
.form-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.form-panel-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.form-panel-title {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
flex: 1;
}
.form-panel-body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.form-panel-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
Form sections (disclosure-based grouping)
----------------------------------------------------------------------- */
.form-section {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
overflow: hidden;
}
.form-section-header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
background: var(--bg-elevated);
border: none;
cursor: pointer;
user-select: none;
text-align: left;
transition: background var(--transition-fast);
}
.form-section-header:hover {
background: var(--bg-hover);
}
.form-section-chevron {
width: 14px;
height: 14px;
color: var(--text-tertiary);
transition: transform var(--transition-normal);
flex-shrink: 0;
}
.form-section.is-open .form-section-chevron {
transform: rotate(90deg);
}
.form-section-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.form-section-body,
.form-section-content {
padding: var(--space-4);
display: none;
}
.form-section.is-open .form-section-body,
.form-section.is-open .form-section-content {
display: block;
}
/* -----------------------------------------------------------------------
Field grid (two-column layout for compact forms)
----------------------------------------------------------------------- */
.form-fields {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
/* Single-column override */
.form-row.full-width {
grid-template-columns: 1fr;
}
/* Three-column for port + protocol combos */
.form-row.triple {
grid-template-columns: 1fr 100px 120px;
}
/* -----------------------------------------------------------------------
Protocol-specific field visibility
Fields are hidden unless their protocol class is active.
----------------------------------------------------------------------- */
.proto-only-ssh,
.proto-only-telnet,
.proto-only-raw {
display: none;
}
.protocol-ssh .proto-only-ssh,
.protocol-telnet .proto-only-telnet,
.protocol-raw .proto-only-raw,
.protocol-github .proto-only-github {
display: flex;
}
/* For block-level protocol fields */
.protocol-ssh .proto-only-ssh.form-section,
.protocol-telnet .proto-only-telnet.form-section,
.protocol-raw .proto-only-raw.form-section,
.protocol-github .proto-only-github.form-section {
display: block;
}
/* -----------------------------------------------------------------------
Tag input
----------------------------------------------------------------------- */
.tag-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
min-height: 30px;
padding: var(--space-1) var(--space-2);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
cursor: text;
transition: border-color var(--transition-fast);
}
.tag-input-wrapper:hover {
border-color: var(--border-strong);
}
.tag-input-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 3px;
height: 20px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
color: var(--accent);
background: var(--accent-muted);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.tag-item-remove {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
font-size: 10px;
cursor: pointer;
border-radius: 50%;
color: var(--accent);
transition: background var(--transition-fast);
}
.tag-item-remove:hover {
background: var(--accent-strong);
}
.tag-input {
flex: 1;
min-width: 60px;
border: none;
background: transparent;
font-size: var(--text-sm);
color: var(--text-primary);
outline: none;
height: 22px;
}
.tag-input::placeholder {
color: var(--text-disabled);
}
/* -----------------------------------------------------------------------
Jump host / proxy list
----------------------------------------------------------------------- */
.jump-host-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.jump-host-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.jump-host-item .jump-host-order {
font-size: var(--text-xs);
color: var(--text-disabled);
min-width: 16px;
text-align: center;
}
.jump-host-item .jump-host-value {
flex: 1;
font-family: var(--font-mono);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jump-host-item .jump-host-actions {
display: flex;
gap: var(--space-1);
opacity: 0;
transition: opacity var(--transition-fast);
}
.jump-host-item:hover .jump-host-actions {
opacity: 1;
}
.jump-host-add {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
font-size: var(--text-sm);
color: var(--text-tertiary);
border: 1px dashed var(--border-default);
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.jump-host-add:hover {
border-color: var(--accent);
color: var(--accent);
}
/* -----------------------------------------------------------------------
Key file selector row
----------------------------------------------------------------------- */
.file-selector {
display: flex;
gap: var(--space-2);
}
.file-selector .form-input {
flex: 1;
}
.file-selector .btn {
flex-shrink: 0;
}
/* File path display */
.file-path {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-tertiary);
background: var(--bg-input);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* -----------------------------------------------------------------------
Inline validation
----------------------------------------------------------------------- */
.form-group.is-valid .form-input {
border-color: var(--success);
}
.form-group.is-valid .form-input:focus {
box-shadow: 0 0 0 2px rgba(102, 187, 106, 0.2);
}
.form-group.is-invalid .form-input {
border-color: var(--error);
}
.form-group.is-invalid .form-input:focus {
box-shadow: 0 0 0 2px rgba(239, 83, 80, 0.2);
}
.form-validation-icon {
position: absolute;
right: var(--space-3);
top: 50%;
transform: translateY(-50%);
font-size: var(--text-sm);
}
.form-group.is-valid .form-validation-icon {
color: var(--success);
}
.form-group.is-invalid .form-validation-icon {
color: var(--error);
}
/* -----------------------------------------------------------------------
Help text under sections
----------------------------------------------------------------------- */
.form-section-help {
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-4) var(--space-3);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Colour preview (for terminal colour settings)
----------------------------------------------------------------------- */
.colour-preview {
display: inline-block;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-default);
vertical-align: middle;
cursor: pointer;
}
/* -----------------------------------------------------------------------
Number input with stepper
----------------------------------------------------------------------- */
.form-number {
display: flex;
align-items: center;
}
.form-number .form-input {
border-radius: var(--radius-md) 0 0 var(--radius-md);
text-align: center;
width: 60px;
/* Hide browser spinners */
-moz-appearance: textfield;
}
.form-number .form-input::-webkit-inner-spin-button,
.form-number .form-input::-webkit-outer-spin-button {
appearance: none;
}
.form-number-btn {
width: 24px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-left: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.form-number-btn:last-child {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
}
.form-number-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Form action row (test connection, etc.)
----------------------------------------------------------------------- */
.form-actions-inline {
display: flex;
align-items: center;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-3);
}
.form-actions-inline .btn {
flex-shrink: 0;
}
.form-actions-inline .form-actions-status {
font-size: var(--text-xs);
color: var(--text-tertiary);
flex: 1;
}
/* -----------------------------------------------------------------------
Form hint — inline help text below fields
----------------------------------------------------------------------- */
.form-hint {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
.form-hint.secret-error {
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);
margin-top: var(--space-1);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Toggle wrapper — label + toggle switch in a row
----------------------------------------------------------------------- */
.form-toggle-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-1) 0;
}
.form-toggle-label {
font-size: var(--text-sm);
color: var(--text-secondary);
flex: 1;
}
/* -----------------------------------------------------------------------
Jump host rows
----------------------------------------------------------------------- */
.jump-hosts-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.jump-host-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.jump-host-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
Port-forwarding rows
----------------------------------------------------------------------- */
.port-forwards-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.port-forward-row {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background: var(--surface-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.port-forward-fields {
display: flex;
gap: var(--space-2);
align-items: center;
}
.port-forward-arrow {
color: var(--text-muted);
font-size: var(--text-sm);
flex-shrink: 0;
}
.port-forward-enabled-label {
display: flex;
gap: var(--space-1);
align-items: center;
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
cursor: pointer;
}
.port-forward-enabled-label input[type="checkbox"] {
accent-color: var(--accent);
}
/* -----------------------------------------------------------------------
Payload preset rows
----------------------------------------------------------------------- */
.payload-presets-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preset-row {
display: grid;
grid-template-columns: 120px 1fr 100px auto;
gap: var(--space-2);
align-items: center;
}
/* -----------------------------------------------------------------------
Small / compact form controls — used inside repeater rows
----------------------------------------------------------------------- */
.form-input-sm,
.form-select-sm {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
/* -----------------------------------------------------------------------
Login Script Editor — expect-style send/wait steps
----------------------------------------------------------------------- */
.form-help {
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-3);
line-height: 1.5;
}
.login-script-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.login-script-empty {
font-size: var(--text-xs);
color: var(--text-muted);
font-style: italic;
padding: var(--space-2) 0;
}
.login-script-row {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.login-script-type {
width: 90px;
min-width: 90px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-value {
flex: 1;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout {
width: 60px;
min-width: 60px;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
height: 30px;
}
.login-script-timeout-label {
font-size: var(--text-xs);
color: var(--text-muted);
min-width: 10px;
}
.login-script-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.login-script-btn {
background: var(--bg-elevated);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
width: 24px;
height: 24px;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s;
}
.login-script-btn:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.login-script-btn-remove:hover {
background: var(--danger);
color: #fff;
}
.login-script-add-row {
display: flex;
gap: var(--space-2);
}
/* -----------------------------------------------------------------------
TOTP / 2FA — secret input row with action buttons
----------------------------------------------------------------------- */
.totp-input-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.totp-input-row .form-input {
flex: 1;
}
/* SSH key path row */
.key-path-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.key-path-row .form-input {
flex: 1;
}
.jump-hosts-list .form-error {
margin-bottom: var(--space-2);
}
.totp-toggle,
.totp-test,
.totp-save,
.totp-delete {
flex-shrink: 0;
white-space: nowrap;
}
.btn-danger {
background: var(--error);
color: #fff;
border: 1px solid transparent;
}
.btn-danger:hover {
background: #d32f2f;
}
-304
View File
@@ -1,304 +0,0 @@
/* =========================================================================
GitHub Repository Viewer
Styles for the non-terminal GitHub connection panel.
========================================================================= */
.github-viewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-family);
}
/* ── Header bar ────────────────────────────────────────────────────────── */
.github-viewer-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.github-viewer-title-area {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.github-viewer-icon {
display: flex;
align-items: center;
color: var(--protocol-github);
opacity: 0.9;
}
.github-viewer-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.github-viewer-search {
flex: 1;
max-width: 360px;
}
.github-viewer-search-input {
width: 100%;
padding: var(--space-1) var(--space-3);
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: var(--font-size-sm);
outline: none;
transition: border-color 0.15s;
}
.github-viewer-search-input:focus {
border-color: var(--protocol-github);
}
.github-viewer-search-input::placeholder {
color: var(--text-muted);
}
.github-viewer-refresh {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.github-viewer-refresh:hover {
background: var(--bg-hover);
color: var(--protocol-github);
border-color: var(--protocol-github);
}
/* ── Status bar ────────────────────────────────────────────────────────── */
.github-viewer-status {
padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5);
font-size: var(--font-size-xs);
color: var(--text-muted);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.github-viewer-status-error {
color: var(--red);
}
/* ── Repo list ─────────────────────────────────────────────────────────── */
.github-viewer-list {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5);
}
.github-viewer-empty {
padding: var(--space-8) var(--space-4);
text-align: center;
color: var(--text-muted);
font-size: var(--font-size-sm);
}
/* ── Group ─────────────────────────────────────────────────────────────── */
.github-group {
margin-bottom: var(--space-4);
}
.github-group-header {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
margin-bottom: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
}
.github-group-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
text-transform: none;
letter-spacing: 0.01em;
}
.github-group-count {
font-size: var(--font-size-xs);
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: var(--radius-full);
}
/* ── Repo card ─────────────────────────────────────────────────────────── */
.github-repo-card {
padding: var(--space-2) var(--space-3);
margin-bottom: 2px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.12s;
}
.github-repo-card:hover {
background: var(--bg-hover);
}
.github-repo-top {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: 2px;
}
.github-repo-commit {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-bottom: 4px;
}
.github-repo-name {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--protocol-github);
}
.github-repo-badge {
font-size: 10px;
padding: 0 5px;
border-radius: var(--radius-full);
line-height: 16px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.github-repo-actions {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.github-repo-download,
.github-repo-visit {
padding: 0 6px;
height: 18px;
border-radius: var(--radius-full);
border: 1px solid var(--border-subtle);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 10px;
letter-spacing: 0.04em;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.github-repo-download:hover,
.github-repo-visit:hover {
background: var(--bg-hover);
color: var(--protocol-github);
border-color: var(--protocol-github);
}
.github-repo-private {
background: rgba(255, 167, 38, 0.12);
color: #ffa726;
}
.github-repo-fork {
background: rgba(78, 205, 196, 0.12);
color: #4ecdc4;
}
.github-repo-archived {
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
}
.github-repo-desc {
font-size: var(--font-size-xs);
color: var(--text-secondary);
line-height: 1.4;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.github-repo-meta {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 11px;
color: var(--text-muted);
}
.github-repo-lang {
display: flex;
align-items: center;
gap: 4px;
}
.github-lang-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.github-repo-stars {
/* Star colour is inherent in the text */
}
.github-repo-pushed {
/* Relative time text */
}
/* ── Scrollbar styling ─────────────────────────────────────────────────── */
.github-viewer-list::-webkit-scrollbar {
width: 6px;
}
.github-viewer-list::-webkit-scrollbar-track {
background: transparent;
}
.github-viewer-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.github-viewer-list::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
-289
View File
@@ -1,289 +0,0 @@
/* ==========================================================================
Alchementary Link - IRC viewer
========================================================================== */
.irc-viewer-panel {
background: var(--bg-base);
}
.irc-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.irc-main {
display: grid;
grid-template-rows: 34px minmax(0, 1fr) 42px;
min-width: 0;
min-height: 0;
}
.irc-room-tabs {
display: flex;
align-items: center;
gap: var(--space-1);
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
padding: var(--space-1) var(--space-2);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.irc-room-tab {
height: 24px;
max-width: 180px;
min-width: 44px;
padding: 0 var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.irc-room-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-tab.active {
background: var(--bg-active);
border-color: var(--border-default);
color: var(--text-primary);
}
.irc-room-tab.has-unread::after {
content: "";
display: inline-block;
width: 5px;
height: 5px;
margin-left: var(--space-1);
border-radius: 50%;
background: var(--protocol-irc);
vertical-align: middle;
}
.irc-terminal {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: var(--space-2);
}
.irc-terminal .xterm {
height: 100%;
}
.irc-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
padding: var(--space-2);
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
}
.irc-input {
min-width: 0;
height: 26px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
.irc-input:focus,
.irc-join-input:focus,
.irc-filter-input:focus {
outline: none;
border-color: var(--border-focus);
}
.irc-send {
height: 26px;
}
.irc-sidebar {
display: grid;
grid-template-rows: minmax(88px, auto) minmax(120px, 1fr) minmax(120px, 1fr);
min-width: 0;
min-height: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
}
.irc-sidebar-section {
min-height: 0;
padding: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
overflow: hidden;
}
.irc-users-section {
border-bottom: 0;
}
.irc-sidebar-title,
.irc-sidebar-title-row {
min-height: 24px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.irc-sidebar-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.irc-join-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.irc-join-input,
.irc-filter-input {
min-width: 0;
height: 24px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-filter-input {
width: 100%;
margin-bottom: var(--space-2);
}
.irc-sidebar-list,
.irc-room-list,
.irc-user-list {
min-height: 0;
max-height: 100%;
overflow-y: auto;
}
.irc-room-row,
.irc-user-row,
.irc-listed-room-main {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.irc-room-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
height: 26px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
}
.irc-room-row:hover,
.irc-user-row:hover,
.irc-listed-room:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.irc-room-row.active {
background: var(--bg-active);
color: var(--text-primary);
}
.irc-room-name,
.irc-listed-room-name,
.irc-listed-room-topic {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-room-count,
.irc-listed-room-count {
color: var(--text-tertiary);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.irc-listed-room {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: 34px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.irc-listed-room-main {
display: grid;
gap: 1px;
}
.irc-listed-room-name {
color: var(--text-primary);
font-size: var(--text-xs);
}
.irc-listed-room-topic {
color: var(--text-tertiary);
font-size: 10px;
}
.irc-user-row {
display: block;
height: 24px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.irc-empty {
padding: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
@media (max-width: 900px) {
.irc-viewer {
grid-template-columns: minmax(0, 1fr);
}
.irc-sidebar {
display: none;
}
}
-241
View File
@@ -1,241 +0,0 @@
/* ==========================================================================
Alchementary Link — Application Layout
Grid shell, sidebar, tab bar, workspace, and status bar structure.
========================================================================== */
/* -----------------------------------------------------------------------
Main application grid
----------------------------------------------------------------------- */
.app {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height);
height: 100vh;
overflow: hidden;
background: var(--bg-base);
}
/* Collapsed sidebar variant */
.app.sidebar-collapsed {
grid-template-columns: var(--sidebar-width-collapsed) 1fr;
}
/* -----------------------------------------------------------------------
Sidebar
----------------------------------------------------------------------- */
.sidebar {
grid-row: 1 / -1;
grid-column: 1;
background: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: var(--z-sticky);
}
/* -----------------------------------------------------------------------
Tab bar
----------------------------------------------------------------------- */
.tab-bar {
grid-column: 2;
grid-row: 1;
position: relative;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
height: var(--tab-bar-height);
min-height: var(--tab-bar-height);
overflow: hidden;
}
.tab-list {
display: flex;
align-items: center;
flex: 1;
height: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-gutter: stable;
scroll-behavior: smooth;
}
.tab-list::-webkit-scrollbar {
height: 6px;
}
.tab-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
.tab-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.tab-bar::before,
.tab-bar::after {
content: "";
position: absolute;
top: 0;
bottom: 1px;
z-index: 2;
width: 18px;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
}
.tab-bar::before {
left: 0;
background: linear-gradient(90deg, var(--bg-surface), transparent);
}
.tab-bar::after {
right: calc(var(--tab-bar-height) * 2);
background: linear-gradient(-90deg, var(--bg-surface), transparent);
}
.tab-bar.has-overflow-left::before,
.tab-bar.has-overflow-right::after {
opacity: 1;
}
/* -----------------------------------------------------------------------
Workspace
----------------------------------------------------------------------- */
.workspace {
grid-column: 2;
grid-row: 2;
position: relative;
overflow: hidden;
background: var(--bg-base);
display: flex;
flex-direction: column;
}
/* Panel container — fills workspace, holds session panels */
.panel-container {
flex: 1;
position: relative;
overflow: hidden;
}
/* Individual session panel — one per tab, fills the panel container */
.session-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Terminal container — fills workspace */
.terminal-container {
position: absolute;
inset: 0;
padding: 0;
}
/* -----------------------------------------------------------------------
Status bar
----------------------------------------------------------------------- */
.status-bar {
grid-column: 2;
grid-row: 3;
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
display: flex;
align-items: center;
padding: 0 var(--space-3);
font-size: var(--text-xs);
color: var(--text-tertiary);
gap: var(--space-4);
white-space: nowrap;
overflow: hidden;
}
.status-bar span {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.status-spacer {
flex: 1;
}
.status-update {
border: 1px solid var(--accent);
background: rgba(78, 205, 196, 0.12);
color: var(--accent);
font-size: var(--text-xs);
padding: 2px 8px;
border-radius: 999px;
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease,
border-color 150ms ease;
}
.status-update:hover {
background: rgba(78, 205, 196, 0.2);
color: var(--text-primary);
border-color: var(--accent-strong, var(--accent));
}
.status-update.is-hidden {
display: none;
}
/* Status bar indicator dots */
.status-bar .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.status-dot.is-connected {
background: var(--success);
}
.status-dot.is-connecting {
background: var(--warning);
}
.status-dot.is-error {
background: var(--error);
}
.status-dot.is-disconnected {
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)
----------------------------------------------------------------------- */
.resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: var(--z-sticky);
}
.resize-handle:hover,
.resize-handle.is-dragging {
background: var(--accent-muted);
}
-752
View File
@@ -1,752 +0,0 @@
/* Leviathan mode inside the Link shell */
.leviathan-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.leviathan-group {
margin: var(--space-3) var(--space-3) var(--space-1);
padding: 0;
color: var(--sidebar-group-title);
cursor: default;
font-size: 10px;
font-weight: var(--weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.leviathan-tool {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: 30px;
margin: 1px var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
}
.leviathan-tool:hover {
border-color: var(--border-subtle);
background: var(--bg-hover);
}
.leviathan-tool.sidebar-item-active {
border-color: rgba(78, 205, 196, 0.35);
background: var(--accent-muted);
}
.leviathan-tool-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--warning);
background: rgba(255, 167, 38, 0.08);
font-family: var(--font-mono);
font-size: 10px;
font-weight: var(--weight-semibold);
}
.leviathan-tool.sidebar-item-active .leviathan-tool-ico {
color: var(--accent);
border-color: rgba(78, 205, 196, 0.35);
background: rgba(78, 205, 196, 0.12);
}
.badge-leviathan {
color: var(--warning);
border-color: rgba(255, 167, 38, 0.35);
}
.leviathan-panel {
flex-direction: column;
min-width: 0;
overflow: hidden;
background: #181818;
}
.leviathan-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 48px;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.leviathan-title-wrap {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.leviathan-tool-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 34px;
height: 30px;
border: 1px solid rgba(255, 167, 38, 0.28);
border-radius: var(--radius-md);
color: var(--warning);
background: rgba(255, 167, 38, 0.08);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
.leviathan-heading {
display: grid;
gap: 1px;
min-width: 0;
}
.leviathan-title-wrap h2 {
margin: 0;
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-eyebrow,
.leviathan-status {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: var(--leading-tight);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-header-actions,
.leviathan-tool-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.leviathan-header-actions .btn,
.leviathan-tool-toolbar .btn {
height: 28px;
}
.leviathan-panel-body {
min-height: 0;
overflow: hidden;
padding: 0;
}
.leviathan-workbench {
display: grid;
grid-template-columns: minmax(260px, 330px) minmax(0, 1fr);
gap: var(--space-3);
height: 100%;
padding: var(--space-3);
}
.leviathan-top-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-2);
}
.leviathan-hex-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.leviathan-control-plane,
.leviathan-output-plane {
min-width: 0;
min-height: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface);
}
.leviathan-control-plane {
display: flex;
flex-direction: column;
gap: var(--space-3);
overflow: auto;
padding: var(--space-3);
}
.leviathan-top-workbench .leviathan-control-plane {
align-items: flex-end;
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-2);
min-height: 42px;
overflow: visible;
padding: var(--space-2) var(--space-3);
}
.leviathan-output-plane {
display: flex;
flex-direction: column;
overflow: hidden;
background: #151515;
}
.leviathan-params {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-2);
}
.leviathan-top-workbench .leviathan-params {
display: flex;
align-items: flex-end;
flex: 1 1 auto;
flex-wrap: wrap;
gap: var(--space-2);
min-width: 180px;
}
.leviathan-param,
.leviathan-param-inline {
display: grid;
gap: var(--space-1);
min-width: 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.leviathan-param > span,
.leviathan-param-inline > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-top-workbench .leviathan-param,
.leviathan-top-workbench .leviathan-param-inline {
min-width: 96px;
}
.leviathan-top-workbench .leviathan-param {
flex: 0 1 150px;
}
.leviathan-top-workbench .leviathan-param-inline {
flex: 0 0 112px;
}
.leviathan-hex-workbench .leviathan-param-inline {
flex-basis: 104px;
}
.leviathan-param .form-input,
.leviathan-param-inline .form-input {
height: 30px;
border-color: var(--border-subtle);
background: var(--bg-input);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.leviathan-param textarea.form-input {
min-height: 118px;
padding: var(--space-2);
line-height: var(--leading-normal);
resize: vertical;
}
.leviathan-top-workbench .leviathan-tool-toolbar {
flex: 0 0 auto;
}
.leviathan-checkbox {
width: 18px;
height: 18px;
min-height: 18px;
padding: 0;
accent-color: var(--accent);
}
.leviathan-file-picker {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-width: 0;
padding-top: var(--space-2);
border-top: 1px solid var(--border-subtle);
}
.leviathan-file-path {
min-width: 0;
overflow: hidden;
color: var(--text-tertiary);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 1px;
flex: 0 0 auto;
min-height: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--border-subtle);
}
.leviathan-summary:empty {
display: none;
}
.leviathan-pill {
min-width: 0;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
}
.leviathan-pill span {
display: block;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: var(--leading-tight);
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.leviathan-pill strong {
display: block;
overflow: hidden;
margin-top: 2px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-results {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
.leviathan-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1px;
border-bottom: 1px solid var(--border-subtle);
background: var(--border-subtle);
}
.leviathan-metric {
min-width: 0;
padding: var(--space-3);
background: #151515;
}
.leviathan-metric span,
.leviathan-kv-row span {
display: block;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: var(--leading-tight);
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.leviathan-metric strong {
display: block;
overflow: hidden;
margin-top: var(--space-1);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-3);
padding: var(--space-3);
}
.leviathan-section {
min-width: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.leviathan-section h3 {
margin: 0;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
.leviathan-kv-list {
display: grid;
}
.leviathan-kv-row {
display: grid;
grid-template-columns: minmax(80px, 120px) minmax(0, 1fr);
gap: var(--space-2);
min-width: 0;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.leviathan-kv-row:last-child {
border-bottom: 0;
}
.leviathan-kv-row strong {
overflow: hidden;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.leviathan-entropy-frame {
min-height: 100%;
padding: var(--space-3);
background: #101010;
}
.leviathan-entropy-canvas {
width: 100%;
height: min(280px, 42vh);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: #101010;
cursor: crosshair;
}
.leviathan-spectrogram-frame {
min-height: 100%;
overflow: auto;
padding: var(--space-3);
background: #05070c;
}
.leviathan-spectrogram-image {
display: block;
max-width: 100%;
height: auto;
image-rendering: pixelated;
}
.leviathan-empty,
.leviathan-json {
min-height: 100%;
color: var(--text-secondary);
background: #151515;
}
.leviathan-empty {
display: grid;
place-items: center;
padding: var(--space-6);
color: var(--text-tertiary);
font-size: var(--text-sm);
text-align: center;
}
.leviathan-error-state {
display: grid;
place-items: center;
min-height: 100%;
gap: var(--space-2);
padding: var(--space-6);
color: var(--text-secondary);
text-align: center;
}
.leviathan-error-icon {
position: relative;
display: grid;
place-items: center;
width: 58px;
height: 52px;
color: #161616;
font-size: 24px;
font-weight: var(--weight-bold);
}
.leviathan-error-icon::before {
position: absolute;
inset: 0;
background: var(--warning);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
content: "";
}
.leviathan-error-icon span {
position: relative;
top: 7px;
}
.leviathan-error-state strong {
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
.leviathan-error-state p {
max-width: 520px;
margin: 0;
color: var(--text-tertiary);
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.leviathan-json {
margin: 0;
padding: var(--space-3);
overflow: auto;
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-normal);
user-select: text;
}
.leviathan-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--text-xs);
}
.leviathan-table th,
.leviathan-table td {
max-width: 320px;
border-bottom: 1px solid var(--border-subtle);
padding: var(--space-2) var(--space-3);
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
vertical-align: top;
white-space: nowrap;
}
.leviathan-offset-link {
color: var(--warning);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: left;
}
.leviathan-offset-link:hover {
color: var(--accent);
}
.leviathan-export-found {
min-width: 58px;
}
.leviathan-muted {
color: var(--text-tertiary);
}
.leviathan-table th {
position: sticky;
top: 0;
z-index: 1;
color: var(--text-tertiary);
background: var(--bg-surface);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
.leviathan-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.leviathan-table-note {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-subtle);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.leviathan-tree {
padding: var(--space-2) var(--space-3);
}
.leviathan-tree-node {
border-left: 1px solid var(--border-subtle);
padding: var(--space-1) 0 var(--space-1) var(--space-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.leviathan-tree-node strong {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.leviathan-tree-node span {
color: var(--text-tertiary);
}
.leviathan-hex-grid {
--leviathan-hex-width: 16;
display: grid;
align-content: start;
gap: 0;
min-height: 100%;
padding: var(--space-1);
overflow: auto;
background: #101010;
font-family: var(--font-mono);
font-size: 10px;
}
.leviathan-hex-row {
display: grid;
grid-template-columns: 82px max-content max-content;
align-items: center;
gap: var(--space-2);
min-height: 23px;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.leviathan-hex-row:hover {
background: rgba(255, 255, 255, 0.035);
}
.leviathan-offset {
color: var(--warning);
font-variant-numeric: tabular-nums;
}
.leviathan-byte-cells {
display: grid;
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
gap: 2px;
}
.leviathan-byte {
width: 22px;
min-height: 20px;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-primary);
background: #1d1d1d;
text-align: center;
font-family: var(--font-mono);
font-size: 10px;
line-height: 18px;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.leviathan-byte:hover,
.leviathan-byte:focus {
border-color: var(--accent);
background: var(--bg-input);
}
.leviathan-byte.is-editing {
border-color: var(--warning);
}
.leviathan-byte.is-saving {
color: var(--accent);
}
.leviathan-byte.is-search-hit {
border-color: var(--warning);
color: var(--text-inverse);
background: var(--warning);
}
.leviathan-ascii {
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 20px;
text-overflow: clip;
white-space: pre;
}
.leviathan-modal-fields {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: var(--space-3);
}
@media (max-width: 1120px) {
.leviathan-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.leviathan-control-plane {
max-height: 260px;
}
.leviathan-params {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
}
@media (max-width: 720px) {
.leviathan-panel-header {
align-items: stretch;
flex-direction: column;
}
.leviathan-header-actions {
justify-content: flex-start;
}
.leviathan-hex-row {
grid-template-columns: 1fr;
gap: var(--space-1);
padding: var(--space-2);
}
.leviathan-byte-cells {
grid-template-columns: repeat(var(--leviathan-hex-width), 22px);
}
}
-285
View File
@@ -1,285 +0,0 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
font-family: monospace;
user-select: text;
white-space: pre;
}
.xterm .xterm-accessibility-tree > div {
transform-origin: left;
width: fit-content;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
/* xterm.js customization: Override xterm's cursor style */
.xterm .xterm-scrollable-element > .scrollbar {
cursor: default;
}
/* Arrows */
.xterm .xterm-scrollable-element > .scrollbar > .scra {
cursor: pointer;
font-size: 11px !important;
}
.xterm .xterm-scrollable-element > .visible {
opacity: 1;
/* Background rule added for IE9 - to allow clicks on dom node */
background:rgba(0,0,0,0);
transition: opacity 100ms linear;
/* In front of peek view */
z-index: 11;
}
.xterm .xterm-scrollable-element > .invisible {
opacity: 0;
pointer-events: none;
}
.xterm .xterm-scrollable-element > .invisible.fade {
transition: opacity 800ms linear;
}
/* Scrollable Content Inset Shadow */
.xterm .xterm-scrollable-element > .shadow {
position: absolute;
display: none;
}
.xterm .xterm-scrollable-element > .shadow.top {
display: block;
top: 0;
left: 3px;
height: 3px;
width: 100%;
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.left {
display: block;
top: 3px;
left: 0;
height: 100%;
width: 3px;
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
display: block;
top: 0;
left: 0;
height: 3px;
width: 3px;
}
.xterm .xterm-scrollable-element > .shadow.top.left {
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
}
-888
View File
@@ -1,888 +0,0 @@
/* ==========================================================================
Alchementary Link — Modals & Drawers
Profile editor drawer, settings modal, known hosts viewer,
confirmation dialogs, host key verification dialog.
========================================================================== */
/* -----------------------------------------------------------------------
Modal backdrop
----------------------------------------------------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: rgba(10, 10, 20, 0.6);
backdrop-filter: blur(2px);
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-normal),
visibility var(--transition-normal);
}
.modal-backdrop.is-visible {
opacity: 1;
visibility: visible;
}
/* -----------------------------------------------------------------------
Modal (centred panel)
----------------------------------------------------------------------- */
.modal {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.96);
width: 480px;
max-width: calc(100vw - var(--space-8));
max-height: calc(100vh - var(--space-8));
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-normal),
visibility var(--transition-normal),
transform var(--transition-normal);
}
.modal.is-visible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -50%) scale(1);
}
/* Size variants */
.modal-sm {
width: 360px;
}
.modal-lg {
width: 640px;
}
.modal-xl {
width: 800px;
}
.modal-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.modal-header-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-lg);
color: var(--text-tertiary);
}
.modal-title {
flex: 1;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.modal-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: var(--text-md);
color: var(--text-tertiary);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
}
.modal-body.sftp-picker-body {
display: flex;
flex-direction: column;
padding: 0;
min-height: 320px;
}
.modal-body p {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
.modal-body p + p {
margin-top: var(--space-3);
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.modal-footer-left {
margin-right: auto;
}
/* -----------------------------------------------------------------------
Drawer (slides in from the right)
Used for the profile editor.
----------------------------------------------------------------------- */
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: rgba(10, 10, 20, 0.5);
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-normal),
visibility var(--transition-normal);
}
.drawer-backdrop.is-visible {
opacity: 1;
visibility: visible;
}
.drawer {
position: fixed;
z-index: var(--z-modal);
top: 0;
right: 0;
width: 480px;
max-width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border-left: 1px solid var(--border-default);
box-shadow: var(--shadow-xl);
transform: translateX(100%);
transition: transform var(--transition-slow);
}
.drawer.is-visible {
transform: translateX(0);
}
/* Drawer width variants */
.drawer-wide {
width: 560px;
}
.drawer-narrow {
width: 380px;
}
.drawer-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.drawer-title {
flex: 1;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.drawer-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: var(--text-md);
color: var(--text-tertiary);
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
}
.drawer-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.drawer-body {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
/* Protocol selector tabs inside the drawer */
.drawer-protocol-tabs {
display: flex;
gap: 0;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.drawer-proto-tab {
position: relative;
padding: var(--space-2) var(--space-4);
background: none;
border: none;
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
letter-spacing: 0.04em;
transition: color var(--transition-fast);
}
.drawer-proto-tab:hover {
color: var(--text-primary);
}
.drawer-proto-tab.active {
color: var(--accent);
}
.drawer-proto-tab.active::after {
content: "";
position: absolute;
bottom: -1px;
left: var(--space-2);
right: var(--space-2);
height: 2px;
background: var(--accent);
border-radius: 1px;
}
/* Settings tab bar */
.settings-tab-bar {
display: flex;
gap: 0;
padding: 0 var(--space-5);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.settings-tab {
position: relative;
padding: var(--space-2) var(--space-3);
background: none;
border: none;
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition: color var(--transition-fast);
}
.settings-tab:hover {
color: var(--text-primary);
}
.settings-tab.active {
color: var(--accent);
}
.settings-tab.active::after {
content: "";
position: absolute;
bottom: -1px;
left: var(--space-1);
right: var(--space-1);
height: 2px;
background: var(--accent);
border-radius: 1px;
}
.settings-body {
padding: var(--space-5);
}
/* Help tab — two-column layout with navigation sidebar */
.settings-body.help-layout {
display: flex;
gap: 0;
padding: 0;
overflow: hidden;
}
.help-nav {
width: 180px;
min-width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: var(--space-2) 0;
border-right: 1px solid var(--border-subtle);
overflow-y: auto;
background: var(--bg-surface);
}
.help-nav-item {
display: block;
width: 100%;
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
color: var(--text-secondary);
background: none;
border: none;
text-align: left;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
border-left: 2px solid transparent;
}
.help-nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.help-nav-item.active {
color: var(--accent);
background: var(--bg-elevated);
border-left-color: var(--accent);
font-weight: var(--weight-medium);
}
.help-content {
flex: 1;
padding: var(--space-4) var(--space-5);
overflow-y: auto;
min-width: 0;
font-size: var(--text-sm);
line-height: 1.65;
color: var(--text-secondary);
}
.help-section-title {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-3) 0;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
}
.help-content h4 {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
margin: var(--space-4) 0 var(--space-2) 0;
}
.help-content p {
margin: 0 0 var(--space-2) 0;
}
.help-content ul,
.help-content ol {
margin: 0 0 var(--space-3) 0;
padding-left: var(--space-5);
}
.help-content li {
margin-bottom: var(--space-1);
}
.help-content code {
font-family: "Menlo", "Consolas", "Courier New", monospace;
font-size: 0.9em;
background: var(--bg-base);
padding: 1px 5px;
border-radius: var(--radius-sm);
color: var(--accent);
}
.help-content kbd {
display: inline-block;
font-family: "Menlo", "Consolas", "Courier New", monospace;
font-size: 0.8em;
padding: 1px 5px;
background: var(--bg-base);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-primary);
box-shadow: 0 1px 0 var(--border-strong, var(--border-default));
line-height: 1.4;
}
.help-content em {
color: var(--text-tertiary);
font-style: italic;
}
.help-content strong {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.help-shortcuts-table {
width: 100%;
border-collapse: collapse;
margin: var(--space-2) 0 var(--space-3) 0;
}
.help-shortcuts-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.help-shortcuts-table td:first-child {
width: 140px;
white-space: nowrap;
color: var(--text-primary);
}
/* Command palette */
.palette-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: rgba(10, 10, 20, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
}
.palette {
width: 520px;
max-width: calc(100vw - var(--space-8));
max-height: 400px;
display: flex;
flex-direction: column;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
}
.palette-input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: transparent;
border: none;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
font-size: var(--text-base);
outline: none;
}
.palette-list {
flex: 1;
overflow-y: auto;
padding: var(--space-1) 0;
}
.palette-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
cursor: pointer;
transition: background var(--transition-fast);
}
.palette-item:hover,
.palette-item-active {
background: var(--bg-hover);
}
.palette-item-label {
flex: 1;
font-size: var(--text-sm);
color: var(--text-primary);
}
.palette-item-detail {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.palette-item-shortcut {
font-size: var(--text-xs);
color: var(--text-tertiary);
background: var(--bg-surface);
padding: var(--space-half) var(--space-1);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
}
.palette-section {
padding: var(--space-1) var(--space-4);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.palette-empty {
padding: var(--space-6) var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Confirmation dialog
----------------------------------------------------------------------- */
.confirm-dialog {
width: 400px;
}
.confirm-dialog .modal-body {
text-align: center;
padding: var(--space-6) var(--space-5);
}
.confirm-dialog-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--space-4);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: var(--text-2xl);
}
.confirm-dialog-icon.is-warning {
background: rgba(255, 167, 38, 0.12);
color: var(--warning);
}
.confirm-dialog-icon.is-danger {
background: rgba(239, 83, 80, 0.12);
color: var(--error);
}
.confirm-dialog-icon.is-info {
background: rgba(66, 165, 245, 0.12);
color: var(--info);
}
.confirm-dialog-message {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
max-width: 300px;
margin: 0 auto;
}
/* -----------------------------------------------------------------------
Host key verification dialog
----------------------------------------------------------------------- */
.hostkey-dialog {
width: 520px;
}
.hostkey-fingerprint {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
background: var(--bg-input);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
word-break: break-all;
line-height: var(--leading-relaxed);
margin: var(--space-3) 0;
}
.hostkey-fingerprint-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-1);
}
.hostkey-mismatch-warning {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: rgba(239, 83, 80, 0.08);
border: 1px solid rgba(239, 83, 80, 0.2);
border-radius: var(--radius-md);
margin: var(--space-3) 0;
}
.hostkey-mismatch-warning .warning-icon {
font-size: var(--text-lg);
color: var(--error);
flex-shrink: 0;
line-height: 1;
margin-top: 2px;
}
.hostkey-mismatch-warning p {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Settings modal
----------------------------------------------------------------------- */
.settings-modal {
width: 640px;
max-height: 80vh;
}
.settings-nav {
display: flex;
gap: 1px;
background: var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: var(--space-4);
}
.settings-nav-item {
flex: 1;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-tertiary);
text-align: center;
background: var(--bg-surface);
border: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.settings-nav-item:hover {
color: var(--text-secondary);
}
.settings-nav-item.active {
background: var(--bg-active);
color: var(--text-primary);
}
.settings-section {
margin-bottom: var(--space-6);
}
.settings-section-title {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
}
/* -----------------------------------------------------------------------
Known hosts viewer
----------------------------------------------------------------------- */
.known-hosts-modal {
width: 700px;
}
.known-hosts-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.known-hosts-item {
display: grid;
grid-template-columns: 1fr 140px 60px;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
background: var(--bg-surface);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.known-hosts-item:hover {
background: var(--bg-hover);
}
.known-hosts-hostname {
font-family: var(--font-mono);
font-weight: var(--weight-medium);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.known-hosts-type {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.known-hosts-actions {
display: flex;
justify-content: flex-end;
}
.known-hosts-empty {
padding: var(--space-8);
text-align: center;
font-size: var(--text-sm);
color: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
Quick connect popover
----------------------------------------------------------------------- */
.quick-connect {
width: 360px;
}
.quick-connect .modal-body {
padding: var(--space-4);
}
.quick-connect-input {
width: 100%;
height: 36px;
padding: 0 var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
}
.quick-connect-input::placeholder {
color: var(--text-disabled);
font-family: var(--font-sans);
}
.quick-connect-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
outline: none;
}
.quick-connect-help {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-top: var(--space-2);
line-height: var(--leading-relaxed);
}
.quick-connect-help code {
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--bg-overlay);
padding: 1px 4px;
border-radius: 2px;
}
/* -----------------------------------------------------------------------
Password strength meter
----------------------------------------------------------------------- */
.password-strength-meter {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.strength-bar {
flex: 1;
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
width: 0;
border-radius: 2px;
transition:
width 0.3s ease,
background-color 0.3s ease;
}
.strength-fill.strength-weak {
background: var(--error);
}
.strength-fill.strength-fair {
background: var(--warning);
}
.strength-fill.strength-good {
background: #66bb6a;
}
.strength-fill.strength-strong {
background: var(--success);
}
.strength-label {
font-size: var(--text-xs);
font-weight: 500;
min-width: 48px;
text-align: right;
}
.strength-label.strength-weak {
color: var(--error);
}
.strength-label.strength-fair {
color: var(--warning);
}
.strength-label.strength-good {
color: #66bb6a;
}
.strength-label.strength-strong {
color: var(--success);
}
-201
View File
@@ -1,201 +0,0 @@
/* ── Session Recording & Playback ────────────────────────────────────── */
/* Recording indicator dot on tabs */
.recording-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error, #ef4444);
margin-right: 6px;
flex-shrink: 0;
animation: recording-pulse 1.2s ease-in-out infinite;
}
@keyframes recording-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Recordings list in browser modal */
.recording-storage {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 0 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border, #3f3f46);
}
.recording-storage-summary {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--text-secondary, #a1a1aa);
}
.recording-cleanup-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.recording-cleanup-status {
min-height: 16px;
}
.recordings-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.recording-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--surface-2, #2a2a2e);
border-radius: 6px;
transition: background 0.15s ease;
}
.recording-item:hover {
background: var(--surface-3, #333338);
}
.recording-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.recording-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #e4e4e7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recording-meta {
font-size: 11px;
color: var(--text-tertiary, #71717a);
}
.recording-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
@media (max-width: 640px) {
.recording-cleanup-controls {
grid-template-columns: 1fr;
}
}
/* Playback modal — larger to fit a terminal */
.modal-playback {
width: 90vw;
max-width: 900px;
max-height: 90vh;
}
.playback-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 !important;
}
.playback-terminal {
flex: 1;
min-height: 300px;
background: #1a1a1e;
border-radius: 4px;
padding: 4px;
margin: 0 12px;
}
.playback-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 12px;
background: var(--surface-1, #1e1e22);
border-top: 1px solid var(--border, #3f3f46);
}
.playback-progress {
flex: 1;
height: 4px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background: var(--surface-3, #333338);
border-radius: 2px;
outline: none;
}
.playback-progress::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent, #3b82f6);
cursor: pointer;
border: none;
}
.playback-time {
font-size: 11px;
color: var(--text-tertiary, #71717a);
white-space: nowrap;
min-width: 80px;
text-align: right;
}
.playback-speed {
font-size: 11px;
background: var(--surface-2, #2a2a2e);
color: var(--text-primary, #e4e4e7);
border: 1px solid var(--border, #3f3f46);
border-radius: 4px;
}
/* Export/import profile list */
.export-profile-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: var(--surface-2, #2a2a2e);
border-radius: 6px;
border: 1px solid var(--border, #3f3f46);
}
.export-profile-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 13px;
color: var(--text-primary, #e4e4e7);
cursor: pointer;
border-radius: 4px;
transition: background 0.1s ease;
}
.export-profile-item:hover {
background: var(--surface-3, #333338);
}
.export-profile-item input[type="checkbox"] {
accent-color: var(--accent, #3b82f6);
}
-514
View File
@@ -1,514 +0,0 @@
/* ==========================================================================
Alchementary Link — SFTP File Browser
Right-side panel for browsing remote files alongside the terminal.
========================================================================== */
/* -----------------------------------------------------------------------
Panel wrapper — overlays the terminal from the right side
----------------------------------------------------------------------- */
.sftp-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 300px;
min-width: 220px;
max-width: 50vw;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
overflow: hidden;
z-index: var(--z-dropdown, 50);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25);
}
/* Resize handle — positioned at the left edge of the SFTP panel */
.sftp-resize-handle {
position: absolute;
top: 0;
left: -3px;
bottom: 0;
width: 6px;
cursor: col-resize;
background: transparent;
z-index: var(--z-dropdown, 50);
transition: background var(--transition-fast);
}
.sftp-resize-handle:hover,
.sftp-resize-handle.is-dragging {
background: var(--accent);
}
/* -----------------------------------------------------------------------
Toolbar (top bar with path breadcrumb + action buttons)
----------------------------------------------------------------------- */
.sftp-toolbar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-elevated);
flex-shrink: 0;
min-height: 34px;
}
.sftp-toolbar-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.sftp-toolbar-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition:
background var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
padding: 0;
}
.sftp-toolbar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-toolbar-btn.is-accent:hover {
color: var(--accent);
}
.sftp-toolbar-btn.is-close:hover {
color: var(--error);
}
/* -----------------------------------------------------------------------
Breadcrumb path
----------------------------------------------------------------------- */
.sftp-breadcrumb {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
font-size: var(--text-xs);
color: var(--text-tertiary);
gap: 1px;
}
.sftp-breadcrumb-segment {
background: none;
border: none;
color: var(--text-secondary);
font-size: var(--text-xs);
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-sm);
white-space: nowrap;
transition:
background var(--transition-fast),
color var(--transition-fast);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
.sftp-breadcrumb-segment:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-breadcrumb-segment:last-child {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.sftp-breadcrumb-sep {
color: var(--text-disabled);
font-size: 10px;
flex-shrink: 0;
user-select: none;
}
/* -----------------------------------------------------------------------
File list
----------------------------------------------------------------------- */
.sftp-file-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Scrollbar styling */
.sftp-file-list::-webkit-scrollbar {
width: 6px;
}
.sftp-file-list::-webkit-scrollbar-track {
background: transparent;
}
.sftp-file-list::-webkit-scrollbar-thumb {
background: var(--border-strong, var(--bg-active));
border-radius: 3px;
}
.sftp-file-list::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* -----------------------------------------------------------------------
File row
----------------------------------------------------------------------- */
.sftp-file-row {
display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
min-height: 30px;
}
.sftp-file-row:hover {
background: var(--bg-hover);
}
.sftp-file-row.is-selected {
background: var(--accent-muted);
}
.sftp-file-row.is-selected:hover {
background: var(--accent-strong);
}
.sftp-file-row.is-readonly .sftp-file-meta {
color: var(--warning);
font-weight: var(--weight-semibold);
}
.sftp-file-row.is-unreadable {
opacity: 0.5;
cursor: not-allowed;
}
.sftp-file-row.is-unreadable .sftp-file-name {
text-decoration: line-through;
}
.sftp-file-icon {
font-size: 13px;
text-align: center;
line-height: 1;
flex-shrink: 0;
color: var(--text-tertiary);
}
.sftp-file-icon.is-dir {
color: var(--accent);
}
.sftp-file-name {
font-size: var(--text-sm);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.sftp-file-meta {
font-size: var(--text-xs);
color: var(--text-tertiary);
white-space: nowrap;
text-align: right;
}
/* -----------------------------------------------------------------------
Parent directory row (..)
----------------------------------------------------------------------- */
.sftp-file-row.is-parent .sftp-file-name {
color: var(--text-secondary);
font-style: italic;
}
/* -----------------------------------------------------------------------
Context actions bar (shown below file list when a file is selected)
----------------------------------------------------------------------- */
.sftp-context-bar {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-top: 1px solid var(--border-subtle);
background: var(--bg-elevated);
flex-shrink: 0;
min-height: 32px;
}
.sftp-context-bar .sftp-ctx-label {
flex: 1;
font-size: var(--text-xs);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.sftp-context-bar .sftp-ctx-btn {
height: 24px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
background: transparent;
color: var(--text-secondary);
transition:
background var(--transition-fast),
color var(--transition-fast);
white-space: nowrap;
}
.sftp-context-bar .sftp-ctx-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sftp-context-bar .sftp-ctx-btn.is-danger:hover {
background: rgba(239, 83, 80, 0.12);
color: var(--error);
}
.sftp-context-bar .sftp-ctx-btn.is-accent:hover {
color: var(--accent);
}
/* -----------------------------------------------------------------------
States: loading, empty, error
----------------------------------------------------------------------- */
.sftp-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-6) var(--space-4);
text-align: center;
gap: var(--space-2);
}
.sftp-state-icon {
font-size: 28px;
color: var(--text-disabled);
}
.sftp-state-msg {
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: var(--leading-relaxed);
max-width: 200px;
}
.sftp-state-retry {
margin-top: var(--space-1);
}
/* Loading spinner (pure CSS) */
.sftp-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-subtle);
border-top-color: var(--accent);
border-radius: 50%;
animation: sftp-spin 0.6s linear infinite;
}
@keyframes sftp-spin {
to {
transform: rotate(360deg);
}
}
/* -----------------------------------------------------------------------
Inline input (for rename / mkdir)
----------------------------------------------------------------------- */
.sftp-inline-input {
width: 100%;
height: 26px;
padding: 0 var(--space-2);
font-size: var(--text-sm);
font-family: inherit;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
outline: none;
box-shadow: 0 0 0 2px var(--accent-muted);
}
/* Picker-specific input in the toolbar */
.sftp-picker-filter {
width: 160px;
height: 26px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
}
.sftp-picker-filter:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
outline: none;
}
.sftp-picker-status {
font-size: var(--text-xs);
color: var(--text-tertiary);
margin-right: var(--space-2);
}
.sftp-picker-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 18px;
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid var(--border-default);
background: var(--bg-surface);
color: var(--text-tertiary);
}
.sftp-picker-badge.is-readonly {
border-color: rgba(255, 167, 38, 0.35);
color: var(--warning);
background: rgba(255, 167, 38, 0.12);
}
.sftp-picker-badge.is-noread {
border-color: rgba(239, 83, 80, 0.35);
color: var(--error);
background: rgba(239, 83, 80, 0.12);
}
.sftp-picker-legend {
display: inline-flex;
align-items: center;
gap: var(--space-1);
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);
}
-566
View File
@@ -1,566 +0,0 @@
/* ==========================================================================
Alchementary Link — Sidebar
Connection manager: logo, search, sections, profile list, footer.
========================================================================== */
/* -----------------------------------------------------------------------
Sidebar header (logo / title / drag region)
----------------------------------------------------------------------- */
.sidebar-header {
display: flex;
align-items: center;
padding: var(--space-3) var(--space-4);
height: var(--tab-bar-height);
min-height: var(--tab-bar-height);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-width: 0;
}
.sidebar-logo svg {
width: 18px;
height: 18px;
color: var(--accent);
flex-shrink: 0;
}
.sidebar-title {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--text-primary);
letter-spacing: 0.02em;
}
.sidebar-product-switch {
display: inline-flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
-webkit-user-select: none;
user-select: none;
}
.sidebar-mode-toggle {
appearance: none;
border: 0;
background: transparent;
padding: 0;
height: auto;
color: var(--text-tertiary);
cursor: pointer;
white-space: nowrap;
-webkit-user-select: none;
user-select: none;
}
#mode-leviathan {
margin-left: auto;
text-align: right;
}
#mode-leviathan[hidden] {
display: none;
}
.sidebar-mode-toggle.is-active {
color: var(--text-primary);
}
.sidebar-brand-gold {
color: var(--sidebar-group-title);
}
.sidebar-mode-toggle:hover {
color: var(--text-secondary);
}
.sidebar-mode-toggle.is-active:hover {
color: var(--text-primary);
}
.mode-leviathan .sidebar-search,
.mode-leviathan .sidebar-footer {
display: none;
}
/* -----------------------------------------------------------------------
Sidebar search
----------------------------------------------------------------------- */
.sidebar-search {
padding: var(--space-2) var(--space-3);
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
Sidebar navigation (scrollable area)
----------------------------------------------------------------------- */
.sidebar-nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: var(--space-2);
}
/* -----------------------------------------------------------------------
Bulk actions bar (multi-select)
----------------------------------------------------------------------- */
.sidebar-bulk-actions {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-bulk-count {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-semibold);
}
.sidebar-bulk-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
/* -----------------------------------------------------------------------
Sections (Favourites, Recent, All Connections)
----------------------------------------------------------------------- */
.sidebar-section {
/* Each section can be collapsed */
}
.sidebar-section + .sidebar-section {
border-top: 1px solid var(--border-subtle);
}
.sidebar-section-header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast);
-webkit-user-select: none;
user-select: none;
text-align: left;
background: none;
border: none;
}
.sidebar-section-header.sidebar-section-header-group .section-title {
color: var(--sidebar-group-title);
}
.sidebar-section-header:hover {
color: var(--text-secondary);
background: var(--bg-hover);
}
.sidebar-section-header.sidebar-section-header-group:hover .section-title {
color: var(--sidebar-group-title);
}
.sidebar-section-header .section-icon {
width: 14px;
text-align: center;
flex-shrink: 0;
font-size: var(--text-sm);
}
.sidebar-section-header .section-chevron {
width: 12px;
height: 12px;
margin-right: var(--space-1);
flex-shrink: 0;
transition: transform var(--transition-normal);
color: var(--text-disabled);
}
.sidebar-section.is-collapsed .section-chevron {
transform: rotate(-90deg);
}
.section-count {
margin-left: auto;
font-size: 10px;
font-weight: var(--weight-normal);
color: var(--text-disabled);
background: var(--bg-overlay);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
min-width: 18px;
text-align: center;
line-height: 16px;
}
/* -----------------------------------------------------------------------
Sidebar list (container for items)
----------------------------------------------------------------------- */
.sidebar-list {
padding: 0 var(--space-2);
}
.sidebar-section.is-collapsed .sidebar-list {
display: none;
}
/* -----------------------------------------------------------------------
Sidebar item (individual connection profile)
----------------------------------------------------------------------- */
.sidebar-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-2);
margin-bottom: 0.5px;
border-radius: var(--radius-md);
cursor: pointer;
transition:
background var(--transition-fast),
box-shadow var(--transition-fast);
user-select: none;
position: relative;
}
.sidebar-item:hover {
background: var(--bg-hover);
}
.sidebar-item:active {
background: var(--bg-active);
}
.sidebar-item-selected {
background: var(--accent-muted);
box-shadow: inset 0 0 0 1px var(--accent);
}
.sidebar-item-selected .sidebar-item-name {
color: var(--text-primary);
}
/* Active / selected state */
.sidebar-item.active {
background: var(--accent-muted);
}
.sidebar-item.active::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
border-radius: var(--radius-full);
background: var(--accent);
}
/* Protocol indicator icon */
.sidebar-item .protocol-indicator {
width: 14px;
height: 14px;
border-radius: var(--radius-sm);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: var(--weight-semibold);
line-height: 1;
background: var(--bg-overlay);
color: var(--text-tertiary);
}
.sidebar-item .protocol-indicator::before {
content: "?";
}
.sidebar-item .protocol-indicator.proto-ssh::before {
content: "⌨";
}
.sidebar-item .protocol-indicator.proto-telnet::before {
content: "⇄";
}
.sidebar-item .protocol-indicator.proto-raw::before {
content: "≋";
}
.sidebar-item .protocol-indicator.proto-github::before {
content: "GH";
}
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-irc::before {
content: "#";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18);
color: var(--protocol-ssh);
}
.sidebar-item .protocol-indicator.proto-telnet {
background: rgba(255, 167, 38, 0.18);
color: var(--protocol-telnet);
}
.sidebar-item .protocol-indicator.proto-raw {
background: rgba(171, 71, 188, 0.18);
color: var(--protocol-raw);
}
.sidebar-item .protocol-indicator.proto-github {
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-irc {
background: rgba(216, 178, 76, 0.18);
color: var(--protocol-irc);
}
.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 {
flex: 1;
min-width: 0;
}
.sidebar-item-name {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-item-host {
font-size: var(--text-xs);
color: var(--text-tertiary);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: var(--leading-tight);
margin-top: 1px;
}
/* Edit button (appears on hover, beside name) */
.sidebar-item-edit {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-disabled);
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition:
opacity var(--transition-fast),
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-item:hover .sidebar-item-edit {
opacity: 1;
}
.sidebar-item-edit:hover {
color: var(--accent);
background: var(--bg-hover);
}
/* Favourite star */
.sidebar-item-star {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-disabled);
border-radius: var(--radius-sm);
opacity: 0;
transition:
opacity var(--transition-fast),
color var(--transition-fast);
}
.sidebar-item:hover .sidebar-item-star {
opacity: 1;
}
.sidebar-item-star.is-favourite {
opacity: 1;
color: var(--warning);
}
.sidebar-item-star:hover {
color: var(--warning);
}
/* Inline status dot (right side, tiny) */
.sidebar-item-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
margin-right: var(--space-1);
}
.sidebar-item-status.is-connected {
background: var(--success);
}
.sidebar-item-status.is-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.sidebar-item-status.is-error {
background: var(--error);
}
/* -----------------------------------------------------------------------
Sidebar empty list placeholder
----------------------------------------------------------------------- */
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
margin: var(--space-6) var(--space-3) 0;
padding: var(--space-5) var(--space-3);
color: var(--text-tertiary);
text-align: center;
}
.sidebar-empty-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
color: var(--accent);
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: 1;
}
.sidebar-empty-title {
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
}
.sidebar-empty-text {
max-width: 190px;
margin: 0;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: var(--leading-normal);
}
.sidebar-empty-actions {
display: flex;
justify-content: center;
margin-top: var(--space-1);
}
.sidebar-list-empty {
padding: var(--space-3) var(--space-2);
font-size: var(--text-xs);
color: var(--text-disabled);
text-align: center;
font-style: italic;
}
/* -----------------------------------------------------------------------
Sidebar footer (actions)
----------------------------------------------------------------------- */
.sidebar-footer {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sidebar-footer .btn:first-child {
flex: 1;
}
/* -----------------------------------------------------------------------
Sidebar collapsed state
----------------------------------------------------------------------- */
.sidebar-collapsed .sidebar-header {
justify-content: center;
padding: var(--space-3) var(--space-2);
}
.sidebar-collapsed .sidebar-title,
.sidebar-collapsed .sidebar-search,
.sidebar-collapsed .sidebar-section-header span:not(.section-icon),
.sidebar-collapsed .sidebar-item-text,
.sidebar-collapsed .sidebar-item-star,
.sidebar-collapsed .section-count,
.sidebar-collapsed .sidebar-footer .btn:first-child span {
display: none;
}
.sidebar-collapsed .sidebar-item {
justify-content: center;
padding: var(--space-2);
}
.sidebar-collapsed .sidebar-section-header {
justify-content: center;
padding: var(--space-2);
}
.sidebar-collapsed .sidebar-footer {
flex-direction: column;
}
-223
View File
@@ -1,223 +0,0 @@
/* ==========================================================================
Alchementary Link — Split Pane Layout
Draggable split panes within session panels for multi-terminal views.
========================================================================== */
/* -----------------------------------------------------------------------
Split container — replaces direct terminal-container when split
----------------------------------------------------------------------- */
.split-container {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.split-container.split-horizontal {
flex-direction: column;
}
.split-container.split-vertical {
flex-direction: row;
}
/* -----------------------------------------------------------------------
Individual pane inside a split
----------------------------------------------------------------------- */
.split-pane {
position: relative;
overflow: hidden;
min-width: 100px;
min-height: 100px;
display: flex;
flex-direction: column;
}
/* Active pane indicator (subtle border glow) */
.split-pane.is-focused {
box-shadow: inset 0 0 0 1px var(--accent-muted);
}
/* -----------------------------------------------------------------------
Pane close button — only visible on hover
----------------------------------------------------------------------- */
.split-pane-close {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-overlay);
color: var(--text-tertiary);
font-size: 11px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
z-index: 5;
}
.split-pane:hover > .split-pane-close {
opacity: 1;
}
.split-pane-close:hover {
background: var(--error);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Draggable divider between panes
----------------------------------------------------------------------- */
.split-divider {
flex-shrink: 0;
background: var(--border-subtle);
position: relative;
z-index: 2;
transition: background var(--transition-fast);
}
.split-divider:hover,
.split-divider.is-dragging {
background: var(--accent);
}
/* Vertical split → side-by-side panes → vertical divider bar */
.split-container.split-vertical > .split-divider {
width: 4px;
cursor: col-resize;
}
/* Horizontal split → stacked panes → horizontal divider bar */
.split-container.split-horizontal > .split-divider {
height: 4px;
cursor: row-resize;
}
/* Invisible larger hit-area for easier grabbing */
.split-divider::after {
content: '';
position: absolute;
}
.split-container.split-vertical > .split-divider::after {
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
.split-container.split-horizontal > .split-divider::after {
left: 0;
right: 0;
top: -4px;
bottom: -4px;
}
/* -----------------------------------------------------------------------
Empty pane placeholder — shown when a new pane needs a session
----------------------------------------------------------------------- */
.split-pane-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
gap: var(--space-3);
color: var(--text-tertiary);
font-size: var(--text-sm);
background: var(--bg-base);
padding: var(--space-4);
}
.split-pane-placeholder-icon {
opacity: 0.4;
}
.split-pane-placeholder-text {
text-align: center;
max-width: 200px;
line-height: var(--leading-normal);
}
/* Profile picker list in the placeholder */
.split-pane-profile-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 200px;
overflow-y: auto;
width: 100%;
max-width: 220px;
margin-top: var(--space-2);
}
.split-pane-profile-list::-webkit-scrollbar {
width: 4px;
}
.split-pane-profile-list::-webkit-scrollbar-track {
background: transparent;
}
.split-pane-profile-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
.split-pane-profile-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-secondary);
font-size: var(--text-sm);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.split-pane-profile-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent-muted);
}
.split-pane-profile-btn .protocol-badge {
font-size: 9px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
opacity: 0.7;
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
Prevent text selection while dragging dividers
----------------------------------------------------------------------- */
body.split-resizing {
cursor: col-resize !important;
user-select: none !important;
-webkit-user-select: none !important;
}
body.split-resizing-row {
cursor: row-resize !important;
user-select: none !important;
-webkit-user-select: none !important;
}
/* Disable pointer events on iframes / xterm canvases during drag */
body.split-resizing .xterm,
body.split-resizing-row .xterm {
pointer-events: none;
}
-496
View File
@@ -1,496 +0,0 @@
/* ==========================================================================
Alchementary Link — Tab Bar & Tabs
Compact session tabs with status indicators, protocol badges, and
close / pin controls.
========================================================================== */
/* -----------------------------------------------------------------------
Individual tab
----------------------------------------------------------------------- */
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: var(--tab-bar-height);
padding: 0 var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
position: relative;
flex-shrink: 0;
border-right: 1px solid var(--border-subtle);
transition:
background var(--transition-fast),
color var(--transition-fast);
user-select: none;
max-width: 200px;
min-width: 0;
}
.tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Active tab */
.tab.tab-active {
background: var(--bg-active);
color: var(--text-primary);
}
/* Active indicator — accent bar at the bottom */
.tab.tab-active::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
border-radius: var(--radius-full) var(--radius-full) 0 0;
}
/* -----------------------------------------------------------------------
Tab icon (small terminal/protocol icon)
----------------------------------------------------------------------- */
.tab-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--text-tertiary);
}
.tab.tab-active .tab-icon {
color: var(--text-secondary);
}
/* -----------------------------------------------------------------------
Tab label
----------------------------------------------------------------------- */
.tab-label,
.tab-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
/* -----------------------------------------------------------------------
Protocol mini-badge inside tab
----------------------------------------------------------------------- */
.tab-badge {
display: inline-flex;
align-items: center;
height: 14px;
padding: 0 3px;
font-size: 9px;
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
border-radius: 2px;
flex-shrink: 0;
line-height: 1;
}
.tab-badge.badge-ssh {
background: rgba(78, 205, 196, 0.15);
color: var(--protocol-ssh);
}
.tab-badge.badge-telnet {
background: rgba(255, 167, 38, 0.15);
color: var(--protocol-telnet);
}
.tab-badge.badge-raw {
background: rgba(171, 71, 188, 0.15);
color: var(--protocol-raw);
}
.tab-badge.badge-github {
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-irc {
background: rgba(216, 178, 76, 0.15);
color: var(--protocol-irc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* -----------------------------------------------------------------------
Status dot (connection state)
----------------------------------------------------------------------- */
.tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-tertiary);
}
.tab-status.is-connected {
background: var(--success);
}
.tab-status.is-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.tab-status.is-reconnecting {
background: var(--warning);
animation: pulse-dot 0.8s ease infinite;
}
.tab-status.is-error {
background: var(--error);
}
.tab-status.is-disconnected {
background: var(--text-tertiary);
}
/* Also match the status-* pattern used by updateSessionState */
.tab-status.status-connected {
background: var(--success);
}
.tab-status.status-connecting {
background: var(--warning);
animation: pulse-dot 1.2s ease infinite;
}
.tab-status.status-reconnecting {
background: var(--warning);
animation: pulse-dot 0.8s ease infinite;
}
.tab-status.status-error {
background: var(--error);
}
.tab-status.status-disconnected {
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)
----------------------------------------------------------------------- */
.tab-sftp {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 10px;
color: var(--text-tertiary);
flex-shrink: 0;
opacity: 0;
transition:
opacity var(--transition-fast),
background var(--transition-fast),
color var(--transition-fast);
cursor: pointer;
background: none;
border: none;
padding: 0;
margin-left: auto;
}
.tab:hover .tab-sftp,
.tab.tab-active .tab-sftp {
opacity: 1;
}
.tab-sftp:hover {
background: var(--bg-active);
color: var(--accent);
}
/* -----------------------------------------------------------------------
Close button — revealed on hover
----------------------------------------------------------------------- */
.tab-close {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
opacity: 0;
transition:
opacity var(--transition-fast),
background var(--transition-fast),
color var(--transition-fast);
cursor: pointer;
margin: 0 2px 0 0;
padding: 0;
border: 0;
background: none;
line-height: 1;
}
.tab:hover .tab-close,
.tab.tab-active .tab-close {
opacity: 1;
}
.tab-close:hover {
background: var(--bg-active);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Pin indicator
----------------------------------------------------------------------- */
.tab-pin {
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--text-disabled);
flex-shrink: 0;
}
.tab.is-pinned .tab-pin {
color: var(--accent);
}
/* Pinned tabs are always narrow */
.tab.is-pinned {
max-width: 100px;
padding: 0 var(--space-2);
}
.tab.is-pinned .tab-label {
/* Hide label on pinned tabs to save space */
display: none;
}
.tab.is-pinned .tab-close {
/* Pinned tabs don't show close by default */
display: none;
}
/* -----------------------------------------------------------------------
Drag handle area (for tab reordering)
----------------------------------------------------------------------- */
.tab-drag-handle {
cursor: grab;
flex: 1;
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
height: 100%;
}
.tab-drag-handle:active {
cursor: grabbing;
}
/* Drag ghost styling */
.tab.is-dragging {
opacity: 0.5;
}
.tab.is-drag-over-left::before {
content: "";
position: absolute;
left: -1px;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--accent);
border-radius: var(--radius-full);
}
.tab.is-drag-over-right::after {
content: "";
position: absolute;
right: -1px;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--accent);
border-radius: var(--radius-full);
/* Note: this re-uses ::after — active tab indicator is overridden while dragging */
}
/* -----------------------------------------------------------------------
New-tab button (+)
----------------------------------------------------------------------- */
.tab-new {
display: flex;
align-items: center;
justify-content: center;
width: var(--tab-bar-height);
height: var(--tab-bar-height);
font-size: var(--text-lg);
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
transition:
background var(--transition-fast),
color var(--transition-fast);
border: none;
background: none;
}
.tab-new:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tab-new:active {
background: var(--bg-active);
}
/* -----------------------------------------------------------------------
Broadcast toggle button
----------------------------------------------------------------------- */
.tab-broadcast {
display: flex;
align-items: center;
justify-content: center;
width: var(--tab-bar-height);
height: var(--tab-bar-height);
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
transition:
background var(--transition-fast),
color var(--transition-fast);
border: none;
background: none;
opacity: 0.5;
}
.tab-broadcast:hover {
background: var(--bg-hover);
color: var(--text-primary);
opacity: 0.8;
}
.tab-broadcast.is-active {
color: var(--error);
opacity: 1;
animation: pulse-dot 1.5s ease infinite;
}
/* -----------------------------------------------------------------------
Tab overflow indicators (chevrons at edges)
----------------------------------------------------------------------- */
.tab-bar-overflow-left,
.tab-bar-overflow-right {
display: none;
align-items: center;
justify-content: center;
width: 20px;
flex-shrink: 0;
font-size: var(--text-sm);
color: var(--text-tertiary);
cursor: pointer;
background: linear-gradient(90deg, var(--bg-surface), transparent);
}
.tab-bar-overflow-right {
background: linear-gradient(-90deg, var(--bg-surface), transparent);
}
.tab-bar.has-overflow-left .tab-bar-overflow-left,
.tab-bar.has-overflow-right .tab-bar-overflow-right {
display: flex;
}
/* -----------------------------------------------------------------------
Tab context menu trigger (on right-click)
----------------------------------------------------------------------- */
.tab-context-area {
position: absolute;
inset: 0;
z-index: 1;
/* Transparent hit area for right-click context menu */
}
/* -----------------------------------------------------------------------
Tab hover tooltip
----------------------------------------------------------------------- */
.tab-tooltip {
max-width: 360px;
white-space: normal;
}
.tab-tooltip-title {
max-width: 320px;
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-tooltip-meta {
margin-top: 2px;
color: var(--text-tertiary);
font-weight: var(--weight-normal);
}
/* -----------------------------------------------------------------------
Modified indicator (unsaved changes)
----------------------------------------------------------------------- */
.tab.is-modified .tab-close::before {
content: "●";
font-size: 8px;
color: var(--accent);
}
-479
View File
@@ -1,479 +0,0 @@
/* ==========================================================================
Alchementary Link — Terminal Integration
xterm.js container, header info strip, RAW mode views, and send bar.
========================================================================== */
/* -----------------------------------------------------------------------
Terminal wrapper — fills the workspace panel
----------------------------------------------------------------------- */
.terminal-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* -----------------------------------------------------------------------
Terminal header / info strip (optional)
Shows connection info above the terminal.
----------------------------------------------------------------------- */
.terminal-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
color: var(--text-tertiary);
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
min-height: 24px;
}
.terminal-header-label {
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.terminal-header-value {
font-family: var(--font-mono);
color: var(--text-tertiary);
}
.terminal-header-separator {
width: 1px;
height: 10px;
background: var(--border-subtle);
}
.terminal-header-spacer {
flex: 1;
}
.terminal-header-actions {
display: flex;
gap: var(--space-1);
}
/* -----------------------------------------------------------------------
xterm.js container
----------------------------------------------------------------------- */
.terminal-container {
flex: 1;
position: relative;
overflow: hidden;
padding: 0;
}
/* -----------------------------------------------------------------------
Disconnected overlay
----------------------------------------------------------------------- */
.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) {
filter: blur(2px);
pointer-events: none;
}
.terminal-disconnect-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 14, 18, 0.68);
z-index: 6;
opacity: 0;
visibility: hidden;
transition:
opacity var(--transition-fast),
visibility var(--transition-fast);
}
.terminal-disconnect-overlay.is-visible {
opacity: 1;
visibility: visible;
}
.terminal-disconnect-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
text-align: center;
padding: var(--space-4) var(--space-5);
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.terminal-disconnect-title {
font-size: var(--text-lg);
letter-spacing: 0.45em;
text-indent: 0.45em;
text-transform: uppercase;
color: var(--text-primary);
font-weight: var(--weight-semibold);
}
.terminal-disconnect-message {
font-size: var(--text-sm);
color: var(--text-secondary);
max-width: 360px;
}
/* xterm.js overrides for consistent look */
.terminal-container .xterm,
.session-panel .xterm {
flex: 1;
height: 100%;
min-height: 0;
padding: 3px 0 0 3px;
}
.terminal-container .xterm-screen,
.session-panel .xterm-screen {
height: 100%;
}
.terminal-container .xterm-viewport,
.session-panel .xterm-viewport {
/* Allow xterm's own scrollbar to work */
overflow-y: auto !important;
}
.terminal-container .xterm-viewport::-webkit-scrollbar,
.session-panel .xterm-viewport::-webkit-scrollbar {
width: 6px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-track,
.session-panel .xterm-viewport::-webkit-scrollbar-track {
background: transparent;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb,
.session-panel .xterm-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--radius-full);
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover,
.session-panel .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Ensure cursor blink is visible */
.terminal-container .xterm-cursor-layer,
.session-panel .xterm-cursor-layer {
z-index: 1;
}
/* Selection styling within terminal */
.terminal-container .xterm .xterm-selection div,
.session-panel .xterm .xterm-selection div {
background: var(--accent-strong) !important;
}
/* -----------------------------------------------------------------------
Terminal search overlay
----------------------------------------------------------------------- */
.terminal-search {
position: absolute;
top: var(--space-2);
right: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1);
background: var(--bg-overlay);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition:
opacity var(--transition-fast),
visibility var(--transition-fast),
transform var(--transition-fast);
}
.terminal-search.is-visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.terminal-search input {
width: 200px;
height: 24px;
padding: 0 var(--space-2);
font-size: var(--text-sm);
font-family: var(--font-mono);
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.terminal-search input:focus {
border-color: var(--accent);
outline: none;
}
.terminal-search-count {
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-1);
white-space: nowrap;
min-width: 70px;
}
.terminal-search-toggle {
font-family: var(--font-mono);
font-size: var(--text-xs);
opacity: 0.5;
transition:
opacity var(--transition-fast),
color var(--transition-fast);
}
.terminal-search-toggle.is-active {
opacity: 1;
color: var(--accent);
}
/* -----------------------------------------------------------------------
RAW mode containers
----------------------------------------------------------------------- */
/* RAW text view — scrollable, monospace output */
.raw-text-view {
flex: 1;
overflow-y: auto;
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
color: var(--text-primary);
background: var(--bg-base);
white-space: pre-wrap;
word-break: break-all;
}
/* RAW hex view */
.raw-hex-view {
flex: 1;
overflow-y: auto;
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-relaxed);
color: var(--text-primary);
background: var(--bg-base);
}
.raw-hex-row {
display: flex;
gap: var(--space-4);
}
.raw-hex-offset {
color: var(--text-tertiary);
min-width: 60px;
}
.raw-hex-bytes {
min-width: 350px;
letter-spacing: 0.08em;
}
.raw-hex-bytes .byte-highlight {
color: var(--accent);
}
.raw-hex-ascii {
color: var(--text-secondary);
}
.raw-hex-ascii .non-printable {
color: var(--text-disabled);
}
/* RAW split view (text on left, hex on right) */
.raw-split-view {
flex: 1;
display: flex;
overflow: hidden;
}
.raw-split-view .raw-text-view,
.raw-split-view .raw-hex-view {
flex: 1;
}
.raw-split-divider {
width: 1px;
background: var(--border-subtle);
flex-shrink: 0;
}
/* -----------------------------------------------------------------------
RAW mode view toggle buttons
----------------------------------------------------------------------- */
.raw-view-toggle {
display: flex;
gap: 1px;
background: var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.raw-view-toggle-btn {
padding: 0 var(--space-2);
height: 22px;
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-tertiary);
background: var(--bg-surface);
border: none;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.raw-view-toggle-btn:hover {
color: var(--text-secondary);
}
.raw-view-toggle-btn.active {
background: var(--bg-active);
color: var(--text-primary);
}
/* -----------------------------------------------------------------------
Send bar (RAW TCP mode — manual data entry)
----------------------------------------------------------------------- */
.send-bar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.send-bar-input {
flex: 1;
height: 28px;
padding: 0 var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast);
}
.send-bar-input:focus {
border-color: var(--accent);
outline: none;
}
.send-bar-input::placeholder {
color: var(--text-disabled);
}
/* Line ending selector */
.send-bar-eol {
height: 28px;
padding: 0 var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--bg-input);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
appearance: none;
cursor: pointer;
min-width: 60px;
}
.send-bar-eol:focus {
border-color: var(--accent);
outline: none;
}
/* Encoding selector */
.send-bar-encoding {
font-size: var(--text-xs);
color: var(--text-tertiary);
padding: 0 var(--space-1);
}
/* -----------------------------------------------------------------------
Session overlay (connecting / error states)
----------------------------------------------------------------------- */
.session-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(28, 28, 28, 0.85);
z-index: 5;
}
.session-overlay-connecting {
text-align: center;
}
.session-overlay-connecting .spinner {
width: 24px;
height: 24px;
margin: 0 auto var(--space-4);
}
.session-overlay-connecting p {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.session-overlay-error {
text-align: center;
max-width: 360px;
}
.session-overlay-error h3 {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--error);
margin-bottom: var(--space-2);
}
.session-overlay-error p {
font-size: var(--text-sm);
color: var(--text-tertiary);
line-height: var(--leading-relaxed);
margin-bottom: var(--space-4);
}
.session-overlay-error .session-error-detail {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-disabled);
background: var(--bg-input);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
text-align: left;
max-height: 120px;
overflow-y: auto;
word-break: break-all;
}
.session-overlay-actions {
display: flex;
gap: var(--space-2);
}
-159
View File
@@ -1,159 +0,0 @@
/* ==========================================================================
Alchementary Link — Design Tokens
Central palette, spacing, typography, and layout constants.
All values consumed via var(--token-name) throughout the application.
========================================================================== */
:root {
/* -----------------------------------------------------------------------
Colour palette — dark charcoal theme
----------------------------------------------------------------------- */
--bg-base: #1c1c1c; /* deepest background */
--bg-surface: #232323; /* primary surface (sidebar, bars) */
--bg-elevated: #2a2a2a; /* elevated panels, cards */
--bg-overlay: #303030; /* overlays, dropdowns */
--bg-hover: #363636; /* hover state backgrounds */
--bg-active: #3c3c3c; /* active / pressed state */
--bg-input: #1e1e1e; /* input field backgrounds */
/* -----------------------------------------------------------------------
Border colours
----------------------------------------------------------------------- */
--border-subtle: rgba(255, 255, 255, 0.07);
--border-default: rgba(255, 255, 255, 0.12);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--accent);
/* -----------------------------------------------------------------------
Text colours
----------------------------------------------------------------------- */
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--text-tertiary: #707070;
--text-disabled: #4a4a4a;
--text-inverse: #1c1c1c;
--sidebar-group-title: #b89d5f;
/* -----------------------------------------------------------------------
Accent colours — teal / cyan
----------------------------------------------------------------------- */
--accent: #4ecdc4;
--accent-hover: #45b7af;
--accent-muted: rgba(78, 205, 196, 0.12);
--accent-strong: rgba(78, 205, 196, 0.22);
/* -----------------------------------------------------------------------
Status colours
----------------------------------------------------------------------- */
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--info: #42a5f5;
/* -----------------------------------------------------------------------
Protocol badge colours
----------------------------------------------------------------------- */
--protocol-ssh: #4ecdc4;
--protocol-telnet: #ffa726;
--protocol-raw: #ab47bc;
--protocol-github: #7c8aff;
--protocol-vnc: #e85d4a;
--protocol-irc: #d8b24c;
--protocol-local: #8bc34a;
/* -----------------------------------------------------------------------
Spacing scale (4 px base unit)
----------------------------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* -----------------------------------------------------------------------
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;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
/* -----------------------------------------------------------------------
Border radius
----------------------------------------------------------------------- */
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* -----------------------------------------------------------------------
Shadows
----------------------------------------------------------------------- */
--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
----------------------------------------------------------------------- */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* -----------------------------------------------------------------------
Z-index layers
----------------------------------------------------------------------- */
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-notification: 500;
--z-tooltip: 600;
/* -----------------------------------------------------------------------
Layout
----------------------------------------------------------------------- */
--sidebar-width: 260px;
--sidebar-width-collapsed: 48px;
--tab-bar-height: 44px;
--toolbar-height: 40px;
--status-bar-height: 24px;
}
/* -----------------------------------------------------------------------
Global input overrides — disable macOS/WebKit auto-capitalisation,
autocorrect, and spellcheck on all text fields.
----------------------------------------------------------------------- */
input,
textarea,
select {
autocapitalize: none;
autocorrect: off;
spellcheck: false;
text-transform: none;
}
-78
View File
@@ -1,78 +0,0 @@
/* 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);
}
File diff suppressed because it is too large Load Diff
-446
View File
@@ -1,446 +0,0 @@
(function () {
/**
* Alchementary Link — command palette (Ctrl+K).
* Provides a searchable overlay for quick access to application commands,
* profile connections, and utility actions.
*/
const { createElement, escapeHtml, fuzzyMatch, $ } = window.AlchementaryLink;
class CommandPalette {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement|null} */
this._overlay = null;
/** @type {string} Current filter query. */
this._query = "";
/** @type {number} Index of the highlighted command. */
this._selectedIndex = 0;
}
// ── Public API ───────────────────────────────────────────────────
/**
* Toggles the command palette open/closed.
*/
toggle() {
if (this._overlay) {
this.close();
} else {
this.open();
}
}
/**
* Opens the command palette.
*/
open() {
if (this._overlay) return;
this._query = "";
this._selectedIndex = 0;
this._render();
}
/**
* Closes the command palette.
*/
close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
// ── Command definitions ──────────────────────────────────────────
/**
* Returns the full list of available commands.
* @returns {Array<{id: string, label: string, shortcut?: string, section?: string, action: Function}>}
*/
getCommands() {
const cmds = [];
// Profile connections
const profiles = this.app.state.profiles || [];
for (const p of profiles) {
cmds.push({
id: `connect-${p.id}`,
label: `Connect to ${p.displayName || p.host}`,
section: "Connections",
detail: `${p.protocol.toUpperCase()} — ${p.host}:${p.port}`,
action: () => this.app.connectProfile(p.id),
});
}
// New connections
cmds.push({
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"),
});
cmds.push({
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",
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",
action: () => this.app.tabs.closeActive(),
});
cmds.push({
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",
action: () => {
const prev = this.app.tabs.getPrevTab();
if (prev) this.app.tabs.setActive(prev);
},
});
// Settings & utility
cmds.push({
id: "settings",
label: "Settings",
section: "Application",
action: () => this.app.settings.open(),
});
cmds.push({
id: "import-profiles",
label: "Import Profiles",
section: "Application",
action: () => this.app.importProfiles(),
});
cmds.push({
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",
action: async () => {
try {
await window.AlchementaryLink.wailsService.LockSecretStore();
this.app.state.setSecretStoreState(false);
this.app.notifications.info("Secret store locked");
} catch (err) {
this.app.notifications.error("Failed to lock: " + err);
}
},
});
} else {
cmds.push({
id: "unlock-secrets",
label: "Unlock Secret Store",
section: "Security",
action: async () => {
try {
const pw =
await this.app.secretPrompt.promptMasterPassword(false);
await window.AlchementaryLink.wailsService.UnlockSecretStore(pw);
this.app.state.setSecretStoreState(true);
this.app.notifications.success("Secret store unlocked");
} catch (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: "split-vertical",
label: "Split Pane Right",
section: "Terminal",
shortcut: "⌘⇧D",
action: () => this.app.splitPane("vertical"),
});
cmds.push({
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),
});
}
return cmds;
}
/**
* Returns commands filtered and sorted by the current query.
* @param {string} query
* @returns {Array<Object>}
*/
filter(query) {
if (!query) return this.getCommands();
return this.getCommands()
.map((cmd) => {
const result = fuzzyMatch(
cmd.label + " " + (cmd.detail || ""),
query,
);
return { ...cmd, ...result };
})
.filter((c) => c.match)
.sort((a, b) => b.score - a.score);
}
/**
* Executes a command and closes the palette.
* @param {Object} command
*/
execute(command) {
this.close();
if (command && typeof command.action === "function") {
command.action();
}
}
// ── Rendering ────────────────────────────────────────────────────
/** @private */
_render() {
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",
});
palette.appendChild(input);
// Results list
const list = createElement("div", "palette-list");
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
this._overlay = overlay;
// Initial render
this._renderList(list, this.filter(""));
// Focus the input
input.focus();
// Event handlers
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");
switch (e.key) {
case "ArrowDown":
e.preventDefault();
this._selectedIndex = Math.min(
this._selectedIndex + 1,
items.length - 1,
);
this._highlightItem(items);
break;
case "ArrowUp":
e.preventDefault();
this._selectedIndex = Math.max(this._selectedIndex - 1, 0);
this._highlightItem(items);
break;
case "Enter": {
e.preventDefault();
const selected = items[this._selectedIndex];
if (selected && selected._command) {
this.execute(selected._command);
}
break;
}
case "Escape":
e.preventDefault();
this.close();
break;
}
});
// Click outside to close
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.close();
});
}
/**
* Renders the filtered command list.
* @param {HTMLElement} list
* @param {Array<Object>} commands
* @private
*/
_renderList(list, commands) {
list.innerHTML = "";
let currentSection = null;
if (commands.length === 0) {
const empty = createElement("div", "palette-empty", {
textContent: "No commands found",
});
list.appendChild(empty);
return;
}
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i];
// Section header
if (cmd.section && cmd.section !== currentSection) {
currentSection = 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" : ""}`,
);
item._command = 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,
});
item.appendChild(detail);
}
// Shortcut badge
if (cmd.shortcut) {
const badge = createElement("span", "palette-item-shortcut", {
textContent: cmd.shortcut,
});
item.appendChild(badge);
}
item.addEventListener("click", () => this.execute(cmd));
item.addEventListener("mouseenter", () => {
this._selectedIndex = i;
this._highlightItem(list.querySelectorAll(".palette-item"));
});
list.appendChild(item);
}
}
/**
* Highlights the currently selected item and scrolls it into view.
* @param {NodeListOf<Element>} items
* @private
*/
_highlightItem(items) {
items.forEach((el, i) => {
el.classList.toggle("palette-item-active", i === this._selectedIndex);
});
const active = items[this._selectedIndex];
if (active) {
active.scrollIntoView({ block: "nearest" });
}
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.CommandPalette = CommandPalette;
})();
-497
View File
@@ -1,497 +0,0 @@
(function () {
/**
* Alchementary Link — GitHub repository viewer.
* Renders a scrollable list of repositories grouped by owner/organisation,
* sorted by last commit date (newest first). Automatically refreshes via
* Wails events emitted by the Go backend.
*/
const { createElement, escapeHtml, relativeTime, $ } = window.AlchementaryLink;
class GitHubViewer {
constructor() {
/** @type {Map<string, GitHubViewerInstance>} sessionID → viewer */
this._viewers = new Map();
}
/**
* Creates a viewer panel for a GitHub session inside the given container.
* @param {string} sessionID
* @param {HTMLElement} container - The .session-panel element.
* @param {string} profileName
*/
createViewer(sessionID, container, profileName) {
if (this._viewers.has(sessionID)) return;
const viewer = new GitHubViewerInstance(
sessionID,
container,
profileName,
);
this._viewers.set(sessionID, viewer);
viewer.render();
}
/**
* 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);
}
}
/**
* Handles incoming repo data from the backend event.
* @param {string} sessionID
* @param {string} login - The authenticated GitHub username.
* @param {Array} repos
*/
updateRepos(sessionID, login, repos) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.setData(login, repos);
}
}
/**
* Updates the status line for a session.
* @param {string} sessionID
* @param {string} message
*/
setStatus(sessionID, message) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.setStatus(message);
}
}
/**
* Handles an error event for a session.
* @param {string} sessionID
* @param {string} message
*/
showError(sessionID, message) {
const viewer = this._viewers.get(sessionID);
if (viewer) {
viewer.showError(message);
}
}
}
class GitHubViewerInstance {
/**
* @param {string} sessionID
* @param {HTMLElement} container
* @param {string} profileName
*/
constructor(sessionID, container, profileName) {
this.sessionID = sessionID;
this.container = container;
this.profileName = profileName;
/** @type {string} */
this._login = "";
/** @type {Array} */
this._repos = [];
/** @type {string} */
this._filter = "";
/** @type {HTMLElement|null} */
this._el = null;
/** @type {HTMLElement|null} */
this._listEl = null;
/** @type {HTMLElement|null} */
this._statusEl = null;
/** @type {number|null} */
this._refreshTimer = null;
}
render() {
this.container.innerHTML = "";
const wrapper = createElement("div", "github-viewer");
// ── Header bar ──────────────────────────────────────────
const header = createElement("div", "github-viewer-header");
const titleArea = createElement("div", "github-viewer-title-area");
const icon = createElement("span", "github-viewer-icon");
icon.innerHTML = this._githubSVG();
titleArea.appendChild(icon);
const titleText = createElement("span", "github-viewer-title");
titleText.textContent = this.profileName || "GitHub";
titleArea.appendChild(titleText);
header.appendChild(titleArea);
// Search / filter
const searchWrap = createElement("div", "github-viewer-search");
const searchInput = createElement("input", "github-viewer-search-input", {
type: "text",
placeholder: "Filter repositories…",
});
searchInput.addEventListener("input", (e) => {
this._filter = e.target.value.toLowerCase();
this._renderList();
});
searchWrap.appendChild(searchInput);
header.appendChild(searchWrap);
// Refresh button
const refreshBtn = createElement("button", "github-viewer-refresh", {
title: "Refresh now",
});
refreshBtn.innerHTML = "↻";
refreshBtn.addEventListener("click", () => this._manualRefresh());
header.appendChild(refreshBtn);
wrapper.appendChild(header);
// ── Status bar ──────────────────────────────────────────
this._statusEl = createElement("div", "github-viewer-status");
this._statusEl.textContent = "Connecting…";
wrapper.appendChild(this._statusEl);
// ── Repo list ───────────────────────────────────────────
this._listEl = createElement("div", "github-viewer-list");
wrapper.appendChild(this._listEl);
this.container.appendChild(wrapper);
this._el = wrapper;
}
/**
* Updates the viewer with fresh data.
* @param {string} login
* @param {Array} repos
*/
setData(login, repos) {
this._login = login;
this._repos = repos || [];
this._updateStatus();
this._renderList();
}
/**
* Updates the status line with a custom message.
* @param {string} message
*/
setStatus(message) {
if (!this._statusEl) return;
this._statusEl.classList.remove("github-viewer-status-error");
this._statusEl.textContent = message || "";
}
/**
* Displays an error message in the status bar.
* @param {string} message
*/
showError(message) {
if (this._statusEl) {
this._statusEl.textContent = `⚠ ${message}`;
this._statusEl.classList.add("github-viewer-status-error");
}
}
destroy() {
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
if (this._el) {
this._el.remove();
this._el = null;
}
}
// ── Private ──────────────────────────────────────────────────
_updateStatus() {
if (!this._statusEl) return;
this._statusEl.classList.remove("github-viewer-status-error");
const count = this._repos.length;
const now = new Date().toLocaleTimeString();
this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`;
}
_renderList() {
if (!this._listEl) return;
this._listEl.innerHTML = "";
// Filter
let filtered = this._repos;
if (this._filter) {
filtered = this._repos.filter(
(r) =>
r.fullName.toLowerCase().includes(this._filter) ||
(r.description || "").toLowerCase().includes(this._filter) ||
(r.language || "").toLowerCase().includes(this._filter),
);
}
if (filtered.length === 0) {
const empty = createElement("div", "github-viewer-empty");
empty.textContent = this._filter
? "No matching repositories."
: "No repositories found.";
this._listEl.appendChild(empty);
return;
}
const commitTimestamp = (repo) => {
const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || "";
const ts = new Date(raw).getTime();
return Number.isNaN(ts) ? 0 : ts;
};
// Group by owner
const groups = new Map();
for (const repo of filtered) {
const owner = repo.owner || "unknown";
if (!groups.has(owner)) groups.set(owner, []);
groups.get(owner).push(repo);
}
// Sort repos within each group by latest commit (newest first)
for (const repos of groups.values()) {
repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a));
}
// Sort groups by most recent commit in each group (login first on ties)
const sortedKeys = [...groups.keys()].sort((a, b) => {
const aRepos = groups.get(a) || [];
const bRepos = groups.get(b) || [];
const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0;
const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0;
if (aMax !== bMax) return bMax - aMax;
if (a === this._login) return -1;
if (b === this._login) return 1;
return a.localeCompare(b);
});
for (const owner of sortedKeys) {
const repos = groups.get(owner);
// Group header
const groupEl = createElement("div", "github-group");
const groupHeader = createElement("div", "github-group-header");
groupHeader.innerHTML = `
<span class="github-group-name">${escapeHtml(owner)}</span>
<span class="github-group-count">${repos.length}</span>
`;
groupEl.appendChild(groupHeader);
// Repo cards
for (const repo of repos) {
groupEl.appendChild(this._renderRepoCard(repo));
}
this._listEl.appendChild(groupEl);
}
}
/**
* Renders a single repository card.
* @param {Object} repo
* @returns {HTMLElement}
*/
_renderRepoCard(repo) {
const card = createElement("div", "github-repo-card");
card.addEventListener("click", () => {
if (repo.htmlURL) window.open(repo.htmlURL, "_blank");
});
// Top row: name + badges
const topRow = createElement("div", "github-repo-top");
const nameEl = createElement("span", "github-repo-name");
nameEl.textContent = repo.name;
topRow.appendChild(nameEl);
if (repo.private) {
const badge = createElement(
"span",
"github-repo-badge github-repo-private",
);
badge.textContent = "Private";
topRow.appendChild(badge);
}
if (repo.fork) {
const badge = createElement(
"span",
"github-repo-badge github-repo-fork",
);
badge.textContent = "Fork";
topRow.appendChild(badge);
}
if (repo.archived) {
const badge = createElement(
"span",
"github-repo-badge github-repo-archived",
);
badge.textContent = "Archived";
topRow.appendChild(badge);
}
const actions = createElement("div", "github-repo-actions");
const downloadBtn = createElement("button", "github-repo-download", {
textContent: "ZIP",
title: "Download repository as zip",
});
downloadBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._downloadRepo(repo);
});
actions.appendChild(downloadBtn);
const visitLink = createElement("button", "github-repo-visit", {
textContent: "Visit",
title: "Open repository in browser",
});
visitLink.addEventListener("click", (e) => {
e.stopPropagation();
if (repo.htmlURL) {
if (window.runtime && window.runtime.BrowserOpenURL) {
window.runtime.BrowserOpenURL(repo.htmlURL);
} else {
window.open(repo.htmlURL, "_blank");
}
}
});
actions.appendChild(visitLink);
topRow.appendChild(actions);
card.appendChild(topRow);
if (repo.latestCommitter && repo.latestCommitAt) {
const commitLine = createElement("div", "github-repo-commit");
const when = new Date(repo.latestCommitAt);
const whenText = isNaN(when.getTime())
? repo.latestCommitAt
: when.toLocaleString();
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
card.appendChild(commitLine);
}
// Description
if (repo.description) {
const desc = createElement("div", "github-repo-desc");
desc.textContent = repo.description;
card.appendChild(desc);
}
// Meta row: language, stars, pushed
const meta = createElement("div", "github-repo-meta");
if (repo.language) {
const lang = createElement("span", "github-repo-lang");
lang.innerHTML = `<span class="github-lang-dot" style="background:${this._langColour(repo.language)}"></span> ${escapeHtml(repo.language)}`;
meta.appendChild(lang);
}
if (repo.stars > 0) {
const stars = createElement("span", "github-repo-stars");
stars.textContent = `★ ${repo.stars.toLocaleString()}`;
meta.appendChild(stars);
}
if (repo.pushedAt) {
const pushed = createElement("span", "github-repo-pushed");
pushed.textContent = relativeTime(repo.pushedAt);
pushed.title = new Date(repo.pushedAt).toLocaleString();
meta.appendChild(pushed);
}
card.appendChild(meta);
return card;
}
async _downloadRepo(repo) {
if (!this._statusEl) return;
const ref = repo.defaultBranch || "";
this._statusEl.classList.remove("github-viewer-status-error");
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
try {
await window.AlchementaryLink.wailsService.DownloadGitHubRepoArchive(
this.sessionID,
repo.fullName,
ref,
);
this._updateStatus();
} catch (err) {
this._statusEl.textContent = `⚠ Download failed: ${err}`;
this._statusEl.classList.add("github-viewer-status-error");
}
}
async _manualRefresh() {
if (!this._statusEl) return;
this._statusEl.textContent = "Refreshing…";
try {
await window.AlchementaryLink.wailsService.RefreshGitHubRepos(this.sessionID);
} catch (err) {
this.showError(err.message || "Refresh failed");
}
}
/**
* Returns a colour for the given programming language.
* Uses a subset of GitHub's linguist colours.
* @param {string} lang
* @returns {string} CSS colour value.
*/
_langColour(lang) {
const colours = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Go: "#00ADD8",
Rust: "#dea584",
Java: "#b07219",
C: "#555555",
"C++": "#f34b7d",
"C#": "#178600",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
Dart: "#00B4AB",
Lua: "#000080",
Scala: "#c22d40",
Elixir: "#6e4a7e",
Haskell: "#5e5086",
Zig: "#ec915c",
Vue: "#41b883",
SCSS: "#c6538c",
};
return colours[lang] || "#8b949e";
}
/**
* GitHub Invertocat SVG icon.
* @returns {string}
*/
_githubSVG() {
return '<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>';
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.GitHubViewer = new GitHubViewer();
})();
-795
View File
@@ -1,795 +0,0 @@
(function () {
/**
* Alchementary Link - IRC viewer.
* Uses xterm.js as the chat stream, with room subtabs, a composer, and
* room/user sidebars.
*/
/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */
const { createElement } = window.AlchementaryLink;
const SERVER_TARGET = "server";
const MAX_LINES_PER_TARGET = 2000;
const MAX_LISTED_ROOMS = 200;
const ANSI_RESET = "\x1b[0m";
const ANSI_DIM = "\x1b[2m";
const ANSI_BOLD = "\x1b[1m";
const ANSI_ITALIC = "\x1b[3m";
const ANSI_UNDERLINE = "\x1b[4m";
const ANSI_REVERSE = "\x1b[7m";
const ANSI_STRIKE = "\x1b[9m";
const ANSI_GREY = "\x1b[38;5;244m";
const ANSI_RED = "\x1b[38;5;203m";
const ANSI_GREEN = "\x1b[38;5;83m";
const ANSI_YELLOW = "\x1b[38;5;221m";
const ANSI_CYAN = "\x1b[38;5;81m";
const ANSI_MAGENTA = "\x1b[38;5;177m";
const NICK_COLOURS = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220];
// IRC colours 00-98 mapped to the xterm 256-colour palette described by
// the modern IRC formatting specification. Colour 99 means terminal default.
const IRC_ANSI_COLOURS = [
15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7,
52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
];
function stripTerminalEscapes(value) {
return String(value || "")
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/\x1b./g, "")
.replace(/[\x80-\x9f]/g, "");
}
function readDecimalColour(input, offset) {
const match = input.slice(offset).match(/^\d{1,2}/);
if (!match) return null;
return { value: Number(match[0]), length: match[0].length };
}
function readHexColour(input, offset) {
const value = input.slice(offset, offset + 6);
if (!/^[0-9a-f]{6}$/i.test(value)) return null;
return { value, length: 6 };
}
function indexedColour(code, background = false) {
if (code === 99 || code < 0 || code >= IRC_ANSI_COLOURS.length) {
return `\x1b[${background ? 49 : 39}m`;
}
return `\x1b[${background ? 48 : 38};5;${IRC_ANSI_COLOURS[code]}m`;
}
function hexColour(value, background = false) {
const red = parseInt(value.slice(0, 2), 16);
const green = parseInt(value.slice(2, 4), 16);
const blue = parseInt(value.slice(4, 6), 16);
return `\x1b[${background ? 48 : 38};2;${red};${green};${blue}m`;
}
function formatIRCText(value) {
const input = stripTerminalEscapes(value);
const toggles = {
"\x02": [ANSI_BOLD, "\x1b[22m", false],
"\x1d": [ANSI_ITALIC, "\x1b[23m", false],
"\x1e": [ANSI_STRIKE, "\x1b[29m", false],
"\x1f": [ANSI_UNDERLINE, "\x1b[24m", false],
"\x16": [ANSI_REVERSE, "\x1b[27m", false],
};
let output = "";
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (toggles[char]) {
const toggle = toggles[char];
toggle[2] = !toggle[2];
output += toggle[toggle[2] ? 0 : 1];
continue;
}
if (char === "\x0f") {
output += ANSI_RESET;
for (const toggle of Object.values(toggles)) toggle[2] = false;
continue;
}
if (char === "\x03" || char === "\x04") {
const readColour = char === "\x03" ? readDecimalColour : readHexColour;
const foreground = readColour(input, index + 1);
if (!foreground) {
output += "\x1b[39;49m";
continue;
}
index += foreground.length;
output +=
char === "\x03"
? indexedColour(foreground.value)
: hexColour(foreground.value);
if (input[index + 1] !== ",") continue;
const background = readColour(input, index + 2);
if (!background) {
output += ",";
index += 1;
continue;
}
index += background.length + 1;
output +=
char === "\x03"
? indexedColour(background.value, true)
: hexColour(background.value, true);
continue;
}
// xterm is already monospace, so the IRC monospace toggle needs no SGR.
if (char === "\x11") continue;
const code = char.charCodeAt(0);
if (code < 0x20 || code === 0x7f) continue;
output += char;
}
return output;
}
function plainIRCText(value) {
return formatIRCText(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
}
function nickColour(nick) {
let hash = 0;
for (const char of String(nick || "")) {
hash = (hash * 31 + char.codePointAt(0)) >>> 0;
}
return `\x1b[38;5;${NICK_COLOURS[hash % NICK_COLOURS.length]}m`;
}
function formatIRCEvent(event, time) {
const from = plainIRCText(event?.from || "");
const text = formatIRCText(event?.text || "");
const subject = plainIRCText(event?.command || "");
const timestamp = `${ANSI_DIM}${ANSI_GREY}[${time}]${ANSI_RESET}`;
const styledNick = from
? `${ANSI_BOLD}${nickColour(from)}${from}${ANSI_RESET}`
: "";
let body;
switch (event?.type) {
case "message":
body = `${ANSI_GREY}<${ANSI_RESET}${styledNick}${ANSI_GREY}>${ANSI_RESET} ${text}`;
break;
case "action":
body = `${ANSI_MAGENTA}${ANSI_ITALIC}* ${styledNick}${ANSI_MAGENTA}${ANSI_ITALIC} ${text}`;
break;
case "notice":
body = `${ANSI_YELLOW}-${styledNick || "notice"}${ANSI_YELLOW}-${ANSI_RESET} ${text}`;
break;
case "join":
body = `${ANSI_GREEN}-->${ANSI_RESET} ${styledNick || "user"} joined`;
break;
case "part":
body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} left${text ? ` (${text})` : ""}`;
break;
case "kick":
body = `${ANSI_RED}<--${ANSI_RESET} ${ANSI_BOLD}${subject || "user"}${ANSI_RESET} was kicked by ${styledNick || "server"}${text ? ` (${text})` : ""}`;
break;
case "quit":
body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} quit${text ? ` (${text})` : ""}`;
break;
case "nick":
body = `${styledNick || "user"} is now known as ${ANSI_BOLD}${plainIRCText(event?.text || "")}${ANSI_RESET}`;
break;
case "topic":
body = `${ANSI_CYAN}${ANSI_BOLD}topic:${ANSI_RESET} ${text}`;
break;
case "error":
body = `${ANSI_RED}${ANSI_BOLD}error:${ANSI_RESET} ${text}`;
break;
case "system":
default:
body = `${ANSI_CYAN}${text}`;
break;
}
return `${timestamp} ${body}${ANSI_RESET}`;
}
class IRCViewer {
constructor() {
this._viewers = new Map();
this.app = null;
}
setApp(app) {
this.app = app;
}
createViewer(sessionID, container, profile) {
if (this._viewers.has(sessionID) || !container) return;
const viewer = new IRCViewerInstance(sessionID, container, profile, this.app);
this._viewers.set(sessionID, viewer);
viewer.render();
}
destroyViewer(sessionID) {
const viewer = this._viewers.get(sessionID);
if (!viewer) return;
viewer.destroy();
this._viewers.delete(sessionID);
}
handleEvent(payload) {
const sessionID = payload?.sessionID;
const event = payload?.event || payload;
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.handleEvent(event);
}
fit(sessionID) {
const viewer = this._viewers.get(sessionID);
if (viewer) viewer.fit();
}
}
class IRCViewerInstance {
constructor(sessionID, container, profile, app) {
this.sessionID = sessionID;
this.container = container;
this.profile = profile || {};
this.app = app || null;
this.term = null;
this.fitAddon = null;
this.searchAddon = null;
this.observer = null;
this.activeTarget = SERVER_TARGET;
this.targets = new Map();
this.users = new Map();
this.joinedRooms = [];
this.roomList = [];
this.roomFilter = "";
this.unreadTargets = new Set();
this.destroyed = false;
this.tabsEl = null;
this.termHost = null;
this.inputEl = null;
this.roomsEl = null;
this.usersEl = null;
this.roomListEl = null;
this.roomFilterEl = null;
this._ensureTarget(SERVER_TARGET, "Server");
}
render() {
this.container.innerHTML = "";
this.container.classList.add("irc-viewer-panel");
const wrapper = createElement("div", "irc-viewer");
const main = createElement("div", "irc-main");
this.tabsEl = createElement("div", "irc-room-tabs");
main.appendChild(this.tabsEl);
this.termHost = createElement("div", "irc-terminal");
main.appendChild(this.termHost);
const composer = createElement("form", "irc-composer");
this.inputEl = createElement("input", "irc-input", {
type: "text",
placeholder: "Message or /command",
autocomplete: "off",
});
const sendBtn = createElement("button", "btn btn-sm btn-primary irc-send", {
type: "submit",
textContent: "Send",
});
composer.appendChild(this.inputEl);
composer.appendChild(sendBtn);
composer.addEventListener("submit", (event) => {
event.preventDefault();
this._sendComposer();
});
this.inputEl.addEventListener("keydown", (event) => {
if (event.key === "Tab" && this._completeMention()) {
event.preventDefault();
}
});
main.appendChild(composer);
const sidebar = this._buildSidebar();
wrapper.appendChild(main);
wrapper.appendChild(sidebar);
this.container.appendChild(wrapper);
this._openTerminal();
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._renderRoomList();
this._appendLine(SERVER_TARGET, {
type: "system",
text: "Connecting...",
timestamp: Date.now(),
});
void this._refreshJoinedRooms();
}
handleEvent(event) {
if (!event || !event.type) return;
if (event.type === "rooms") {
this.joinedRooms = Array.isArray(event.rooms) ? event.rooms : [];
for (const room of this.joinedRooms) {
this._ensureTarget(room.name, room.name);
}
this._renderTabs();
this._renderRooms();
return;
}
if (event.type === "users") {
const target = event.target;
if (target) {
this.users.set(target, Array.isArray(event.users) ? event.users : []);
if (target === this.activeTarget) this._renderUsers();
}
return;
}
if (event.type === "room-list") {
this.roomList = Array.isArray(event.rooms) ? event.rooms : [];
this._renderRoomList();
return;
}
const target = this._eventTarget(event);
this._ensureTarget(target, this._targetLabel(target));
this._appendLine(target, event);
if (target !== this.activeTarget) {
this.unreadTargets.add(target);
this._renderTabs();
}
if (["join", "part", "kick", "topic"].includes(event.type)) {
this._renderRooms();
}
}
fit() {
if (!this.fitAddon) return;
requestAnimationFrame(() => {
try {
this.fitAddon.fit();
} catch (_) {
/* noop */
}
});
}
destroy() {
this.destroyed = true;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.term) {
this.term.dispose();
this.term = null;
}
this.fitAddon = null;
this.searchAddon = null;
this.targets.clear();
this.users.clear();
this.unreadTargets.clear();
this.joinedRooms = [];
this.roomList = [];
this.container.classList.remove("irc-viewer-panel");
this.container.innerHTML = "";
}
_buildSidebar() {
const sidebar = createElement("aside", "irc-sidebar");
const joinSection = createElement("div", "irc-sidebar-section");
joinSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Rooms" }),
);
const joinRow = createElement("form", "irc-join-row");
const joinInput = createElement("input", "irc-join-input", {
type: "text",
placeholder: "#room",
autocomplete: "off",
});
const joinBtn = createElement("button", "btn btn-xs btn-secondary", {
type: "submit",
textContent: "Join",
});
joinRow.appendChild(joinInput);
joinRow.appendChild(joinBtn);
joinRow.addEventListener("submit", async (event) => {
event.preventDefault();
const room = joinInput.value.trim();
if (!room) return;
try {
await window.AlchementaryLink.wailsService.IRCJoin(this.sessionID, room);
joinInput.value = "";
} catch (err) {
this._notifyError("Join failed: " + err);
}
});
joinSection.appendChild(joinRow);
this.roomsEl = createElement("div", "irc-sidebar-list");
joinSection.appendChild(this.roomsEl);
sidebar.appendChild(joinSection);
const listSection = createElement("div", "irc-sidebar-section");
const listTitleRow = createElement("div", "irc-sidebar-title-row");
listTitleRow.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Room List" }),
);
const listBtn = createElement("button", "btn btn-xs btn-outline", {
type: "button",
textContent: "List",
});
listBtn.addEventListener("click", async () => {
this.roomList = [];
this._renderRoomList("Loading rooms...");
try {
await window.AlchementaryLink.wailsService.IRCListRooms(this.sessionID);
} catch (err) {
this._renderRoomList("Room list failed");
this._notifyError("Room list failed: " + err);
}
});
listTitleRow.appendChild(listBtn);
listSection.appendChild(listTitleRow);
this.roomFilterEl = createElement("input", "irc-filter-input", {
type: "text",
placeholder: "Filter rooms",
autocomplete: "off",
});
this.roomFilterEl.addEventListener("input", () => {
this.roomFilter = this.roomFilterEl.value.toLowerCase();
this._renderRoomList();
});
listSection.appendChild(this.roomFilterEl);
this.roomListEl = createElement("div", "irc-room-list");
listSection.appendChild(this.roomListEl);
sidebar.appendChild(listSection);
const usersSection = createElement("div", "irc-sidebar-section irc-users-section");
usersSection.appendChild(
createElement("div", "irc-sidebar-title", { textContent: "Users" }),
);
this.usersEl = createElement("div", "irc-user-list");
usersSection.appendChild(this.usersEl);
sidebar.appendChild(usersSection);
return sidebar;
}
_openTerminal() {
const themeName =
(this.app?.state?.preferences || {}).terminalTheme || "charcoal";
this.term = new Terminal({
theme: window.AlchementaryLink.getTerminalTheme(themeName),
fontFamily:
this.profile.terminalFontFamily ||
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
fontSize: this.profile.terminalFontSize || 14,
scrollback: this.profile.terminalScrollback || 10000,
cursorBlink: false,
convertEol: true,
disableStdin: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.searchAddon = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fitAddon);
this.term.loadAddon(this.searchAddon);
try {
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_event, uri) => {
window.runtime.BrowserOpenURL(uri);
}));
} catch (_) {
/* optional */
}
this.term.open(this.termHost);
this.fit();
this.observer = new ResizeObserver(() => this.fit());
this.observer.observe(this.termHost);
}
_renderTabs() {
if (!this.tabsEl) return;
this.tabsEl.innerHTML = "";
for (const [target, state] of this.targets) {
const btn = createElement(
"button",
`irc-room-tab ${target === this.activeTarget ? "active" : ""} ${
this.unreadTargets.has(target) ? "has-unread" : ""
}`,
{
type: "button",
textContent: state.label,
title: state.label,
},
);
btn.addEventListener("click", () => this._setActiveTarget(target));
this.tabsEl.appendChild(btn);
}
}
_renderRooms() {
if (!this.roomsEl) return;
this.roomsEl.innerHTML = "";
if (this.joinedRooms.length === 0) {
this.roomsEl.appendChild(
createElement("div", "irc-empty", { textContent: "No joined rooms" }),
);
return;
}
for (const room of this.joinedRooms) {
const row = createElement("button", "irc-room-row", {
type: "button",
title: plainIRCText(room.topic || room.name),
});
row.classList.toggle("active", room.name === this.activeTarget);
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
row.appendChild(
createElement("span", "irc-room-count", {
textContent: String(room.users || 0),
}),
);
row.addEventListener("click", () => this._setActiveTarget(room.name));
this.roomsEl.appendChild(row);
}
}
_renderUsers() {
if (!this.usersEl) return;
this.usersEl.innerHTML = "";
const users = this.users.get(this.activeTarget) || [];
if (!this._isRoom(this.activeTarget)) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No room selected" }),
);
return;
}
if (users.length === 0) {
this.usersEl.appendChild(
createElement("div", "irc-empty", { textContent: "No users yet" }),
);
return;
}
for (const user of users) {
const row = createElement("button", "irc-user-row", {
type: "button",
textContent: this._displayNick(user),
title: user,
});
row.addEventListener("click", () => {
this._insertMention(this._displayNick(user));
});
this.usersEl.appendChild(row);
}
}
_renderRoomList(message = "") {
if (!this.roomListEl) return;
this.roomListEl.innerHTML = "";
if (message) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: message }),
);
return;
}
let rooms = this.roomList;
if (this.roomFilter) {
rooms = rooms.filter((room) => {
const haystack = `${room.name || ""} ${room.topic || ""}`.toLowerCase();
return haystack.includes(this.roomFilter);
});
}
rooms = rooms.slice(0, MAX_LISTED_ROOMS);
if (rooms.length === 0) {
this.roomListEl.appendChild(
createElement("div", "irc-empty", { textContent: "No rooms loaded" }),
);
return;
}
for (const room of rooms) {
const row = createElement("div", "irc-listed-room");
const meta = createElement("button", "irc-listed-room-main", {
type: "button",
title: plainIRCText(room.topic || room.name),
});
meta.appendChild(
createElement("span", "irc-listed-room-name", {
textContent: room.name || "",
}),
);
meta.appendChild(
createElement("span", "irc-listed-room-topic", {
textContent: plainIRCText(room.topic || ""),
}),
);
meta.addEventListener("click", () => this._joinRoom(room.name));
const count = createElement("span", "irc-listed-room-count", {
textContent: String(room.users || 0),
});
row.appendChild(meta);
row.appendChild(count);
this.roomListEl.appendChild(row);
}
}
_setActiveTarget(target) {
this._ensureTarget(target, this._targetLabel(target));
this.activeTarget = target;
this.unreadTargets.delete(target);
this._renderTabs();
this._renderRooms();
this._renderUsers();
this._replayActiveTarget();
if (this.inputEl) this.inputEl.focus();
}
_appendLine(target, event) {
const state = this._ensureTarget(target, this._targetLabel(target));
const line = this._formatEvent(event);
if (!line) return;
state.lines.push(line);
if (state.lines.length > MAX_LINES_PER_TARGET) {
state.lines.splice(0, state.lines.length - MAX_LINES_PER_TARGET);
}
if (target === this.activeTarget && this.term) {
this.term.writeln(line);
this.term.scrollToBottom();
}
}
_replayActiveTarget() {
if (!this.term) return;
this.term.clear();
const state = this.targets.get(this.activeTarget);
for (const line of state?.lines || []) {
this.term.writeln(line);
}
this.term.scrollToBottom();
this.fit();
}
async _sendComposer() {
const text = this.inputEl.value;
if (!text.trim()) return;
this.inputEl.value = "";
try {
await window.AlchementaryLink.wailsService.IRCHandleInput(
this.sessionID,
this.activeTarget,
text,
);
} catch (err) {
this._notifyError(String(err));
this._appendLine(this.activeTarget, {
type: "error",
text: String(err),
timestamp: Date.now(),
});
}
}
async _joinRoom(room) {
if (!room) return;
try {
await window.AlchementaryLink.wailsService.IRCJoin(this.sessionID, room);
} catch (err) {
this._notifyError("Join failed: " + err);
}
}
async _refreshJoinedRooms() {
try {
const rooms = await window.AlchementaryLink.wailsService.IRCRooms(this.sessionID);
if (this.destroyed) return;
this.handleEvent({ type: "rooms", rooms: Array.isArray(rooms) ? rooms : [] });
} catch (_) {
// The session may have closed while the viewer was being created.
}
}
_completeMention() {
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
const start = this.inputEl.selectionStart || 0;
const before = this.inputEl.value.slice(0, start);
const match = before.match(/(^|\s)@([^\s@]*)$/);
if (!match) return false;
const prefix = match[2].toLowerCase();
const users = this.users.get(this.activeTarget) || [];
const nick = users
.map((user) => this._displayNick(user))
.find((user) => user.toLowerCase().startsWith(prefix));
if (!nick) return false;
const replacement = `${match[1]}@${nick} `;
const nextValue =
this.inputEl.value.slice(0, start - match[0].length) +
replacement +
this.inputEl.value.slice(start);
const caret = start - match[0].length + replacement.length;
this.inputEl.value = nextValue;
this.inputEl.setSelectionRange(caret, caret);
return true;
}
_insertMention(nick) {
if (!this.inputEl) return;
const current = this.inputEl.value;
const spacer = current && !/\s$/.test(current) ? " " : "";
this.inputEl.value = `${current}${spacer}@${nick} `;
this.inputEl.focus();
}
_ensureTarget(target, label) {
const safeTarget = target || SERVER_TARGET;
if (!this.targets.has(safeTarget)) {
this.targets.set(safeTarget, {
label: label || this._targetLabel(safeTarget),
lines: [],
});
}
return this.targets.get(safeTarget);
}
_eventTarget(event) {
if (!event) return SERVER_TARGET;
if (event.target) return event.target;
return SERVER_TARGET;
}
_targetLabel(target) {
return target === SERVER_TARGET ? "Server" : target;
}
_formatEvent(event) {
return formatIRCEvent(event, this._time(event.timestamp));
}
_time(value) {
const date = value ? new Date(value) : new Date();
const hours = String(date.getHours()).padStart(2, "0");
const mins = String(date.getMinutes()).padStart(2, "0");
const secs = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${mins}:${secs}`;
}
_sanitize(value) {
return plainIRCText(value);
}
_displayNick(user) {
return String(user || "").replace(/^[~&@%+]+/, "");
}
_isRoom(target) {
return /^[#&+!]/.test(target || "");
}
_notifyError(message) {
if (this.app?.notifications) {
this.app.notifications.error(message);
}
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.IRCFormatting = Object.freeze({
formatEvent: formatIRCEvent,
formatText: formatIRCText,
plainText: plainIRCText,
});
window.AlchementaryLink.IRCViewer = new IRCViewer();
})();
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
//# sourceMappingURL=addon-web-links.js.map
File diff suppressed because one or more lines are too long
-101
View File
@@ -1,101 +0,0 @@
(function() {
/**
* Alchementary Link — toast notification system.
* Provides non-blocking, auto-dismissing notifications that stack
* in the top-right corner of the viewport.
*/
const { createElement, escapeHtml } = window.AlchementaryLink;
/** Small SVG icons for each notification type (16×16 viewBox). */
const ICONS = {
success: '✓',
error: '✕',
warning: '!',
info: 'i',
};
class Notifications {
constructor() {
/** @type {HTMLElement} */
this.container = document.getElementById('notifications');
if (!this.container) {
this.container = createElement('div', 'notifications-container', { id: 'notifications' });
document.body.appendChild(this.container);
}
}
/**
* Displays a notification.
* @param {string} message - Notification text.
* @param {'info'|'success'|'warning'|'error'} [type='info'] - Severity.
* @param {number} [duration=4000] - Auto-dismiss delay in ms.
*/
show(message, type = 'info', duration = 4000) {
const el = createElement('div', `notification notification-${type}`);
// Icon
const icon = createElement('span', 'notification-icon', {
textContent: ICONS[type] || ICONS.info,
});
el.appendChild(icon);
// Body
const body = createElement('div', 'notification-body');
const msg = createElement('span', 'notification-message', { textContent: message });
body.appendChild(msg);
el.appendChild(body);
// Dismiss button
const close = createElement('button', 'notification-close', {
textContent: '×',
'aria-label': 'Dismiss',
});
close.addEventListener('click', () => this._dismiss(el));
el.appendChild(close);
// Insert at the top of the stack
this.container.prepend(el);
// Auto-dismiss
if (duration > 0) {
el._timer = setTimeout(() => this._dismiss(el), duration);
}
}
/** @param {string} message */
success(message) { this.show(message, 'success'); }
/** @param {string} message */
error(message) { this.show(message, 'error', 6000); }
/** @param {string} message */
warning(message) { this.show(message, 'warning'); }
/** @param {string} message */
info(message) { this.show(message, 'info'); }
/**
* Animates out and removes a notification element.
* @param {HTMLElement} el
* @private
*/
_dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
clearTimeout(el._timer);
el.classList.add('is-leaving');
el.addEventListener('animationend', () => el.remove(), { once: true });
// Safety net — remove after 500 ms even if animation event does not fire
setTimeout(() => {
if (el.parentNode) el.remove();
}, 500);
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.Notifications = Notifications;
})();
File diff suppressed because it is too large Load Diff
-625
View File
@@ -1,625 +0,0 @@
(function () {
/**
* Alchementary Link — RAW TCP data viewer.
* Provides text, hex, and split display modes for raw socket data,
* along with a send bar, pause/resume, and capture saving.
*/
const { createElement, escapeHtml, formatTimestamp, formatBytes } =
window.AlchementaryLink;
/**
* Maximum number of data entries retained in the scrollback buffer.
* @type {number}
*/
const MAX_BUFFER_ENTRIES = 5000;
class RawViewer {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → viewer state.
* @type {Map<string, Object>}
*/
this.viewers = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Creates a RAW viewer inside the given panel container.
* @param {string} sessionId
* @param {string} containerId - DOM id of the panel element.
* @param {Object} profile
*/
createViewer(sessionId, containerId, profile) {
const container = document.getElementById(containerId);
if (!container) return;
const state = {
mode: "text", // text | hex | split
paused: false,
buffer: [],
totalBytes: 0,
sendFormat: "text", // text | hex | escaped
};
// ── Toolbar ──────────────────────────────────────────────
const toolbar = createElement("div", "raw-toolbar");
// Mode toggle
const modeGroup = createElement("div", "raw-mode-group");
for (const mode of ["text", "hex", "split"]) {
const btn = createElement(
"button",
`raw-mode-btn ${mode === state.mode ? "active" : ""}`,
{
textContent: mode.charAt(0).toUpperCase() + mode.slice(1),
dataset: { mode },
},
);
btn.addEventListener("click", () => this._setMode(sessionId, mode));
modeGroup.appendChild(btn);
}
toolbar.appendChild(modeGroup);
// Controls
const controls = createElement("div", "raw-controls");
const pauseBtn = createElement(
"button",
"btn btn-sm btn-outline raw-pause",
{ textContent: "⏸ Pause" },
);
pauseBtn.addEventListener("click", () => this.togglePause(sessionId));
controls.appendChild(pauseBtn);
const clearBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "✕ Clear",
});
clearBtn.addEventListener("click", () => this.clearBuffer(sessionId));
controls.appendChild(clearBtn);
const saveBtn = createElement("button", "btn btn-sm btn-outline", {
textContent: "💾 Save",
});
saveBtn.addEventListener("click", () => this.saveCapture(sessionId));
controls.appendChild(saveBtn);
// Byte counter
const counter = createElement("span", "raw-counter", {
textContent: "0 B",
});
controls.appendChild(counter);
toolbar.appendChild(controls);
container.appendChild(toolbar);
// ── Data display ─────────────────────────────────────────
const display = createElement("div", "raw-display");
container.appendChild(display);
// ── Send bar ─────────────────────────────────────────────
const sendBar = this._renderSendBar(sessionId, profile);
container.appendChild(sendBar);
this.viewers.set(sessionId, {
...state,
container,
display,
toolbar,
pauseBtn,
counter,
profile,
});
}
/**
* Processes incoming data from the backend.
* @param {Object} payload - { sessionID, data, direction, timestamp }
*/
handleData(payload) {
const viewer = this.viewers.get(payload.sessionID);
if (!viewer) return;
// Decode base64 payload
let rawStr;
try {
rawStr = atob(payload.data);
} catch (_) {
rawStr = payload.data || "";
}
const bytes = new Uint8Array(rawStr.length);
for (let i = 0; i < rawStr.length; i++) {
bytes[i] = rawStr.charCodeAt(i);
}
const entry = {
direction: payload.direction || "recv", // 'send' | 'recv'
timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
data: rawStr,
bytes,
};
viewer.buffer.push(entry);
viewer.totalBytes += bytes.length;
// Trim buffer if it exceeds the maximum
if (viewer.buffer.length > MAX_BUFFER_ENTRIES) {
viewer.buffer.splice(0, viewer.buffer.length - MAX_BUFFER_ENTRIES);
}
// Update counter
viewer.counter.textContent = formatBytes(viewer.totalBytes);
// Render unless paused
if (!viewer.paused) {
this._renderEntry(viewer, entry);
this._scrollToBottom(viewer);
}
}
/**
* Toggles pause/resume on the data display.
* @param {string} sessionId
*/
togglePause(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.paused = !viewer.paused;
viewer.pauseBtn.textContent = viewer.paused ? "▶ Resume" : "⏸ Pause";
viewer.pauseBtn.classList.toggle("active", viewer.paused);
// On resume, flush any buffered entries
if (!viewer.paused) {
this._renderAllEntries(viewer);
}
}
/**
* Clears the data buffer and display.
* @param {string} sessionId
*/
clearBuffer(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.buffer = [];
viewer.totalBytes = 0;
viewer.display.innerHTML = "";
viewer.counter.textContent = "0 B";
}
/**
* Prompts the user to save the capture buffer.
* @param {string} sessionId
*/
async saveCapture(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer || viewer.buffer.length === 0) {
this.app.notifications.warning("Nothing to save");
return;
}
try {
const lines = viewer.buffer.map((e) => {
const ts = formatTimestamp(e.timestamp);
const dir = e.direction === "send" ? "TX" : "RX";
return `[${ts}] [${dir}] ${e.data}`;
});
const content = lines.join("\n");
await window.AlchementaryLink.wailsService.SaveSessionCapture(sessionId, content);
this.app.notifications.success("Capture saved");
} catch (err) {
this.app.notifications.error("Save failed: " + err);
}
}
/**
* Sends data from the send bar to the backend.
* @param {string} sessionId
*/
async send(sessionId) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
const input = viewer.container.querySelector(".raw-send-input");
const formatSelect = viewer.container.querySelector(".raw-send-format");
if (!input) return;
const rawValue = input.value;
if (!rawValue) return;
const format = formatSelect?.value || "text";
let dataToSend;
switch (format) {
case "hex":
dataToSend = this._hexToString(rawValue);
break;
case "escaped":
dataToSend = this._unescapeString(rawValue);
break;
default:
dataToSend = rawValue;
}
try {
// For hex/escaped, data is already raw bytes (0-255) so btoa is correct.
// For text mode, UTF-8 encode so non-ASCII characters are transmitted properly.
const b64 =
format === "hex" || format === "escaped"
? btoa(dataToSend)
: window.AlchementaryLink.utf8ToBase64(dataToSend);
await window.AlchementaryLink.wailsService.WriteSession(sessionId, b64);
input.value = "";
// Also show in viewer as sent data
this.handleData({
sessionID: sessionId,
data: b64,
direction: "send",
timestamp: new Date().toISOString(),
});
} catch (err) {
this.app.notifications.error("Send failed: " + err);
}
}
/**
* Tears down a viewer and frees resources.
* @param {string} sessionId
*/
destroyViewer(sessionId) {
this.viewers.delete(sessionId);
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Renders the send bar at the bottom of the viewer.
* @param {string} sessionId
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSendBar(sessionId, profile) {
const bar = createElement("div", "raw-send-bar");
// Format selector
const formatSelect = createElement(
"select",
"form-select form-select-sm raw-send-format",
);
for (const [value, label] of [
["text", "Text"],
["hex", "Hex"],
["escaped", "Escaped"],
]) {
const opt = createElement("option", "", { value, textContent: label });
formatSelect.appendChild(opt);
}
bar.appendChild(formatSelect);
// Input
const input = createElement("input", "form-input raw-send-input", {
type: "text",
placeholder: "Type data to send…",
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.send(sessionId);
});
bar.appendChild(input);
// Quick-insert buttons for common control bytes
const quickBtns = createElement("div", "raw-quick-btns");
const controlBytes = [
{ label: "NUL", value: "\\x00" },
{ label: "CR", value: "\\r" },
{ label: "LF", value: "\\n" },
{ label: "ETX", value: "\\x03" },
{ label: "EOT", value: "\\x04" },
];
for (const cb of controlBytes) {
const btn = createElement("button", "btn btn-xs btn-outline", {
textContent: cb.label,
title: cb.value,
});
btn.addEventListener("click", () => {
input.value += cb.value;
input.focus();
});
quickBtns.appendChild(btn);
}
bar.appendChild(quickBtns);
// Payload preset dropdown
const presets = profile?.raw?.payloadPresets || [];
if (presets.length > 0) {
const presetSelect = createElement(
"select",
"form-select form-select-sm raw-preset-select",
);
const defaultOpt = createElement("option", "", {
value: "",
textContent: "Presets…",
});
presetSelect.appendChild(defaultOpt);
for (const ps of presets) {
const opt = createElement("option", "", {
value: ps.payload,
textContent: ps.name,
});
opt.dataset.format = ps.format;
presetSelect.appendChild(opt);
}
presetSelect.addEventListener("change", () => {
if (presetSelect.value) {
input.value = presetSelect.value;
const selOpt = presetSelect.selectedOptions[0];
if (selOpt?.dataset.format) {
formatSelect.value = selOpt.dataset.format;
}
presetSelect.value = "";
input.focus();
}
});
bar.appendChild(presetSelect);
}
// Send button
const sendBtn = createElement("button", "btn btn-sm btn-primary", {
textContent: "Send",
});
sendBtn.addEventListener("click", () => this.send(sessionId));
bar.appendChild(sendBtn);
return bar;
}
/**
* Renders a single data entry into the display area.
* @param {Object} viewer
* @param {Object} entry
* @private
*/
_renderEntry(viewer, entry) {
let el;
switch (viewer.mode) {
case "hex":
el = this._renderHexView(entry);
break;
case "split":
el = this._renderSplitView(entry, viewer.profile);
break;
default:
el = this._renderTextView(entry, viewer.profile);
}
viewer.display.appendChild(el);
}
/**
* Re-renders all buffered entries (e.g. after mode change or resume).
* @param {Object} viewer
* @private
*/
_renderAllEntries(viewer) {
viewer.display.innerHTML = "";
for (const entry of viewer.buffer) {
this._renderEntry(viewer, entry);
}
this._scrollToBottom(viewer);
}
/**
* Renders a text-mode entry.
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderTextView(entry, profile) {
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
let prefix = "";
if (raw.showTimestamps) {
prefix += `[${formatTimestamp(entry.timestamp)}] `;
}
if (raw.showDirection) {
prefix += entry.direction === "send" ? "[TX] " : "[RX] ";
}
const pre = createElement("pre", "raw-text");
pre.textContent = prefix + entry.data;
row.appendChild(pre);
return row;
}
/**
* Renders a hex-mode entry.
* @param {Object} entry
* @returns {HTMLElement}
* @private
*/
_renderHexView(entry) {
const row = createElement(
"div",
`raw-entry raw-entry-${entry.direction}`,
);
const pre = createElement("pre", "raw-hex");
pre.textContent = this.formatHexDump(entry.bytes);
row.appendChild(pre);
return row;
}
/**
* Renders a split-mode entry (text + hex side by side).
* @param {Object} entry
* @param {Object} profile
* @returns {HTMLElement}
* @private
*/
_renderSplitView(entry, profile) {
const row = createElement(
"div",
`raw-entry raw-entry-split raw-entry-${entry.direction}`,
);
const raw = profile?.raw || {};
// Direction/timestamp header
const header = createElement("div", "raw-split-header");
let headerText = "";
if (raw.showTimestamps)
headerText += formatTimestamp(entry.timestamp) + " ";
headerText += entry.direction === "send" ? "▲ TX" : "▼ RX";
header.textContent = headerText;
row.appendChild(header);
// Split panes
const panes = createElement("div", "raw-split-panes");
const textPane = createElement("pre", "raw-text raw-split-text");
textPane.textContent = entry.data;
panes.appendChild(textPane);
const hexPane = createElement("pre", "raw-hex raw-split-hex");
hexPane.textContent = this.formatHexDump(entry.bytes);
panes.appendChild(hexPane);
row.appendChild(panes);
return row;
}
/**
* Formats a Uint8Array into a traditional hex dump:
* offset | hex bytes | ASCII representation.
* @param {Uint8Array} bytes
* @returns {string}
*/
formatHexDump(bytes) {
const lines = [];
const bytesPerLine = 16;
for (let offset = 0; offset < bytes.length; offset += bytesPerLine) {
const chunk = bytes.slice(offset, offset + bytesPerLine);
// Offset column
const offsetStr = offset.toString(16).padStart(8, "0");
// Hex column
const hexParts = [];
for (let i = 0; i < bytesPerLine; i++) {
if (i < chunk.length) {
hexParts.push(chunk[i].toString(16).padStart(2, "0"));
} else {
hexParts.push(" ");
}
// Extra space after 8th byte for readability
if (i === 7) hexParts.push("");
}
const hexStr = hexParts.join(" ");
// ASCII column
let ascii = "";
for (let i = 0; i < chunk.length; i++) {
const b = chunk[i];
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : ".";
}
lines.push(`${offsetStr} ${hexStr} |${ascii}|`);
}
return lines.join("\n");
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Changes the display mode (text, hex, split) and re-renders.
* @param {string} sessionId
* @param {string} mode
* @private
*/
_setMode(sessionId, mode) {
const viewer = this.viewers.get(sessionId);
if (!viewer) return;
viewer.mode = mode;
// Update active button
viewer.toolbar.querySelectorAll(".raw-mode-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
this._renderAllEntries(viewer);
}
/**
* Scrolls the data display to the bottom.
* @param {Object} viewer
* @private
*/
_scrollToBottom(viewer) {
requestAnimationFrame(() => {
viewer.display.scrollTop = viewer.display.scrollHeight;
});
}
/**
* Converts a hex string (e.g. "48656c6c6f") into a character string.
* @param {string} hex
* @returns {string}
* @private
*/
_hexToString(hex) {
const cleaned = hex.replace(/\s+/g, "");
let str = "";
for (let i = 0; i < cleaned.length; i += 2) {
str += String.fromCharCode(parseInt(cleaned.substring(i, i + 2), 16));
}
return str;
}
/**
* Converts C-style escape sequences (\\n, \\r, \\x00, etc.) into their literal bytes.
* @param {string} str
* @returns {string}
* @private
*/
_unescapeString(str) {
return str.replace(/\\(n|r|t|0|x[0-9a-fA-F]{2}|\\)/g, (match, seq) => {
switch (seq) {
case "n":
return "\n";
case "r":
return "\r";
case "t":
return "\t";
case "0":
return "\0";
case "\\":
return "\\";
default:
// \\xHH
if (seq.startsWith("x")) {
return String.fromCharCode(parseInt(seq.slice(1), 16));
}
return match;
}
});
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.RawViewer = RawViewer;
})();
-701
View File
@@ -1,701 +0,0 @@
(function() {
/**
* Alchementary Link — Session Recording & Playback.
* Provides controls for starting/stopping recordings on active sessions,
* listing saved recordings, and a terminal-based playback viewer with
* speed controls and a progress scrubber.
*/
const { createElement, formatDuration } = window.AlchementaryLink;
// ---------------------------------------------------------------------------
// Recording Manager
// ---------------------------------------------------------------------------
class RecordingManager {
constructor(app) {
this.app = app;
/** @type {Set<string>} Session IDs currently being recorded. */
this.activeSessions = new Set();
}
/**
* Toggles recording for the given session.
* @param {string} sessionId
*/
async toggle(sessionId) {
if (this.activeSessions.has(sessionId)) {
await this.stop(sessionId);
} else {
await this.start(sessionId);
}
}
/**
* Starts recording a session.
* @param {string} sessionId
*/
async start(sessionId) {
try {
const entry = this.app.terminals.terminals.get(sessionId);
const cols = entry ? entry.term.cols : 80;
const rows = entry ? entry.term.rows : 24;
await window.AlchementaryLink.wailsService.StartRecording(sessionId, cols, rows);
this.activeSessions.add(sessionId);
this._updateRecordingIndicator(sessionId, true);
this.app.notifications.success('Recording started');
} catch (err) {
this.app.notifications.error('Failed to start recording: ' + err);
}
}
/**
* Stops recording a session.
* @param {string} sessionId
*/
async stop(sessionId) {
try {
await window.AlchementaryLink.wailsService.StopRecording(sessionId);
this.activeSessions.delete(sessionId);
this._updateRecordingIndicator(sessionId, false);
this.app.notifications.success('Recording saved');
} catch (err) {
this.app.notifications.error('Failed to stop recording: ' + err);
}
}
/**
* Drops frontend recording state when a session is closed. The backend
* finalises the recorder as part of DisconnectSession.
* @param {string} sessionId
*/
cleanup(sessionId) {
this.activeSessions.delete(sessionId);
this._updateRecordingIndicator(sessionId, false);
}
/**
* Opens the recordings browser modal.
*/
async openBrowser() {
try {
const [recordings, storageInfo] = await Promise.all([
window.AlchementaryLink.wailsService.ListRecordings(),
window.AlchementaryLink.wailsService.GetRecordingStorageInfo(),
]);
this._showBrowserModal(recordings || [], storageInfo || {});
} catch (err) {
this.app.notifications.error('Failed to list recordings: ' + err);
}
}
/**
* Plays back a recording in a modal terminal viewer.
* @param {string} id — Recording ID.
*/
async playback(id) {
try {
const data = await window.AlchementaryLink.wailsService.GetRecording(id);
this._showPlaybackModal(data);
} catch (err) {
this.app.notifications.error('Failed to load recording: ' + err);
}
}
/**
* Deletes a recording by ID.
* @param {string} id
*/
async deleteRecording(id) {
try {
await window.AlchementaryLink.wailsService.DeleteRecording(id);
this.app.notifications.success('Recording deleted');
} catch (err) {
this.app.notifications.error('Failed to delete recording: ' + err);
}
}
// ── Recording indicator on tabs ──────────────────────────────────
/**
* Updates the recording indicator on a session tab.
* @param {string} sessionId
* @param {boolean} recording
* @private
*/
_updateRecordingIndicator(sessionId, recording) {
const tab = document.querySelector(`.session-tab[data-session-id="${sessionId}"]`);
if (!tab) return;
let dot = tab.querySelector('.recording-dot');
if (recording) {
if (!dot) {
dot = createElement('span', 'recording-dot');
dot.title = 'Recording';
const label = tab.querySelector('.tab-label');
if (label) label.parentNode.insertBefore(dot, label);
}
} else {
if (dot) dot.remove();
}
}
// ── Recordings browser modal ─────────────────────────────────────
/**
* Renders a modal listing all saved recordings.
* @param {Array} recordings
* @param {Object} storageInfo
* @private
*/
_showBrowserModal(recordings, storageInfo = {}) {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-lg');
let listHTML = '';
if (recordings.length === 0) {
listHTML = '<p class="text-muted" style="padding:1rem;text-align:center;">No recordings yet. Use the record button on a terminal tab to capture a session.</p>';
} else {
listHTML = '<div class="recordings-list">';
for (const rec of recordings) {
const date = new Date(rec.createdAt).toLocaleString();
const dur = _formatSeconds(rec.duration);
const size = _formatBytes(rec.size);
listHTML += `
<div class="recording-item" data-id="${rec.id}">
<div class="recording-info">
<span class="recording-title">${_esc(rec.title || rec.id)}</span>
<span class="recording-meta">${date} · ${dur} · ${size} · ${rec.width}×${rec.height}</span>
</div>
<div class="recording-actions">
<button class="btn btn-xs btn-primary rec-play" data-id="${rec.id}" title="Play">▶</button>
<button class="btn btn-xs btn-danger rec-delete" data-id="${rec.id}" title="Delete">✕</button>
</div>
</div>
`;
}
listHTML += '</div>';
}
const totalSize = _formatBytes(storageInfo.totalBytes || 0);
const count = storageInfo.count ?? recordings.length;
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Session Recordings</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body" style="max-height:60vh;overflow-y:auto;">
<div class="recording-storage">
<div class="recording-storage-summary">
<span>${count} recording${count === 1 ? '' : 's'}</span>
<span>${totalSize}</span>
</div>
<div class="recording-cleanup-controls">
<select class="form-input recording-cleanup-age">
<option value="30">Older than 30 days</option>
<option value="7">Older than 7 days</option>
<option value="90">Older than 90 days</option>
<option value="0">Ignore age</option>
</select>
<select class="form-input recording-cleanup-size">
<option value="1073741824">Keep under 1 GB</option>
<option value="5368709120">Keep under 5 GB</option>
<option value="0">Ignore size</option>
</select>
<button class="btn btn-sm recording-cleanup">Clean up</button>
</div>
<div class="form-help recording-cleanup-status"></div>
</div>
${listHTML}
</div>
<div class="modal-footer">
<button class="btn btn-sm modal-close-btn">Close</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.modal-close-btn').addEventListener('click', close);
modal.querySelector('.recording-cleanup').addEventListener('click', async () => {
const ageDays = parseInt(modal.querySelector('.recording-cleanup-age').value, 10) || 0;
const maxBytes = parseInt(modal.querySelector('.recording-cleanup-size').value, 10) || 0;
const status = modal.querySelector('.recording-cleanup-status');
const btn = modal.querySelector('.recording-cleanup');
status.textContent = '';
btn.disabled = true;
try {
const result = await window.AlchementaryLink.wailsService.CleanupRecordings(ageDays, maxBytes);
status.textContent = `Deleted ${result.deleted || 0}; freed ${_formatBytes(result.freedBytes || 0)}.`;
const fresh = await window.AlchementaryLink.wailsService.ListRecordings();
close();
this._showBrowserModal(fresh || [], await window.AlchementaryLink.wailsService.GetRecordingStorageInfo());
} catch (err) {
status.textContent = 'Cleanup failed: ' + err;
} finally {
btn.disabled = false;
}
});
// Play buttons
modal.querySelectorAll('.rec-play').forEach(btn => {
btn.addEventListener('click', () => {
close();
this.playback(btn.dataset.id);
});
});
// Delete buttons
modal.querySelectorAll('.rec-delete').forEach(btn => {
btn.addEventListener('click', async () => {
await this.deleteRecording(btn.dataset.id);
const item = btn.closest('.recording-item');
if (item) item.remove();
});
});
}
// ── Playback modal with embedded terminal ────────────────────────
/**
* Shows a playback modal with an xterm.js terminal, progress bar, and speed controls.
* @param {Object} data — { header, events }
* @private
*/
_showPlaybackModal(data) {
const header = data.header;
const events = data.events || [];
if (events.length === 0) {
this.app.notifications.info('Recording is empty — nothing to play back.');
return;
}
const totalDuration = events[events.length - 1][0] || 1;
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-playback');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">${_esc(header.title || 'Recording Playback')}</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body playback-body">
<div id="playback-terminal" class="playback-terminal"></div>
<div class="playback-controls">
<button class="btn btn-xs btn-primary playback-play-pause" title="Play/Pause">▶</button>
<button class="btn btn-xs btn-outline playback-restart" title="Restart">⟲</button>
<input type="range" class="playback-progress" min="0" max="1000" value="0" />
<span class="playback-time">0:00 / ${_formatSeconds(totalDuration)}</span>
<select class="playback-speed form-input" style="width:auto;padding:2px 6px;font-size:11px;">
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="5">5×</option>
<option value="10">10×</option>
</select>
</div>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
// Create xterm instance for playback.
const termContainer = modal.querySelector('#playback-terminal');
const { getTerminalTheme } = window.AlchementaryLink;
const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal';
const theme = getTerminalTheme ? getTerminalTheme(themeName) : {};
const term = new window.Terminal({
cols: header.width || 80,
rows: header.height || 24,
theme: theme,
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace',
fontSize: 13,
cursorBlink: false,
disableStdin: true,
scrollback: 10000,
});
term.open(termContainer);
const fitAddon = new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
setTimeout(() => fitAddon.fit(), 50);
// Playback state.
let playing = false;
let speed = 1;
let eventIndex = 0;
let playbackTimer = null;
let startRealTime = 0;
let pausedAt = 0; // elapsed virtual time when paused
const playPauseBtn = modal.querySelector('.playback-play-pause');
const restartBtn = modal.querySelector('.playback-restart');
const progressInput = modal.querySelector('.playback-progress');
const timeLabel = modal.querySelector('.playback-time');
const speedSelect = modal.querySelector('.playback-speed');
const updateTimeDisplay = (elapsed) => {
timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`;
progressInput.value = Math.round((elapsed / totalDuration) * 1000);
};
const scheduleNext = () => {
if (!playing || eventIndex >= events.length) {
if (eventIndex >= events.length) {
playing = false;
playPauseBtn.textContent = '▶';
}
return;
}
const evt = events[eventIndex];
const evtTime = evt[0]; // seconds from start
const realElapsed = (Date.now() - startRealTime) / 1000;
const virtualElapsed = pausedAt + realElapsed * speed;
const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000);
playbackTimer = setTimeout(() => {
if (!playing) return;
// Write output events to the terminal.
if (evt[1] === 'o') {
term.write(evt[2]);
}
eventIndex++;
const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed;
updateTimeDisplay(Math.min(currentVirtual, totalDuration));
scheduleNext();
}, delay);
};
const play = () => {
if (eventIndex >= events.length) {
// Restart from beginning.
eventIndex = 0;
pausedAt = 0;
term.reset();
}
playing = true;
playPauseBtn.textContent = '⏸';
startRealTime = Date.now();
scheduleNext();
};
const pause = () => {
playing = false;
playPauseBtn.textContent = '▶';
if (playbackTimer) {
clearTimeout(playbackTimer);
playbackTimer = null;
}
const realElapsed = (Date.now() - startRealTime) / 1000;
pausedAt = pausedAt + realElapsed * speed;
};
const seekTo = (fraction) => {
const wasPlaying = playing;
if (playing) pause();
const targetTime = fraction * totalDuration;
term.reset();
eventIndex = 0;
// Replay all events up to targetTime.
for (let i = 0; i < events.length; i++) {
if (events[i][0] > targetTime) break;
if (events[i][1] === 'o') {
term.write(events[i][2]);
}
eventIndex = i + 1;
}
pausedAt = targetTime;
updateTimeDisplay(targetTime);
if (wasPlaying) play();
};
playPauseBtn.addEventListener('click', () => {
if (playing) pause(); else play();
});
restartBtn.addEventListener('click', () => {
if (playing) pause();
term.reset();
eventIndex = 0;
pausedAt = 0;
updateTimeDisplay(0);
});
progressInput.addEventListener('input', () => {
seekTo(parseInt(progressInput.value, 10) / 1000);
});
speedSelect.addEventListener('change', () => {
const newSpeed = parseFloat(speedSelect.value);
if (playing) {
const realElapsed = (Date.now() - startRealTime) / 1000;
pausedAt = pausedAt + realElapsed * speed;
speed = newSpeed;
startRealTime = Date.now();
if (playbackTimer) clearTimeout(playbackTimer);
scheduleNext();
} else {
speed = newSpeed;
}
});
// Auto-play on open.
setTimeout(play, 200);
// Close handler.
const close = () => {
if (playing) pause();
term.dispose();
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
}
}
// ---------------------------------------------------------------------------
// Encrypted Export/Import UI
// ---------------------------------------------------------------------------
class ExportImportUI {
constructor(app) {
this.app = app;
}
/**
* Shows the encrypted export modal.
*/
async showExportModal() {
const profiles = this.app.state.profiles || [];
if (profiles.length === 0) {
this.app.notifications.info('No profiles to export.');
return;
}
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-md');
let profileListHTML = profiles.map(p => `
<label class="export-profile-item">
<input type="checkbox" value="${p.id}" checked />
<span>${_esc(p.displayName || p.host)} <small class="text-muted">(${p.protocol})</small></span>
</label>
`).join('');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Export Profiles (Encrypted)</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Select profiles to export</label>
<div class="export-profile-list" style="max-height:200px;overflow-y:auto;margin-bottom:12px;">
${profileListHTML}
</div>
</div>
<div class="form-group">
<label class="form-label">Export password</label>
<input type="password" class="form-input export-password" placeholder="Enter a strong password" />
<small class="text-muted">This password will be required to import the file.</small>
</div>
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input export-password-confirm" placeholder="Confirm password" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm export-cancel">Cancel</button>
<button class="btn btn-sm btn-primary export-confirm">Export</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.export-cancel').addEventListener('click', close);
modal.querySelector('.export-confirm').addEventListener('click', async () => {
const password = modal.querySelector('.export-password').value;
const confirm = modal.querySelector('.export-password-confirm').value;
if (!password) {
this.app.notifications.error('Please enter a password.');
return;
}
if (password !== confirm) {
this.app.notifications.error('Passwords do not match.');
return;
}
const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked');
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
if (selectedIds.length === 0) {
this.app.notifications.error('Please select at least one profile.');
return;
}
try {
const path = await window.AlchementaryLink.wailsService.ExportProfilesEncrypted(selectedIds, password);
this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`);
close();
} catch (err) {
if (String(err).includes('cancelled')) {
return;
}
this.app.notifications.error('Export failed: ' + err);
}
});
}
/**
* Shows the encrypted import modal.
*/
async showImportModal() {
const backdrop = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-md');
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Import Profiles (Encrypted)</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Decryption password</label>
<input type="password" class="form-input import-password" placeholder="Enter the export password" />
</div>
<div class="form-group">
<label class="form-label">Conflict strategy</label>
<select class="form-input import-strategy">
<option value="skip">Skip existing</option>
<option value="overwrite">Overwrite existing</option>
<option value="duplicate">Create duplicates</option>
</select>
</div>
<div class="form-error import-status" aria-live="polite"></div>
</div>
<div class="modal-footer">
<button class="btn btn-sm import-cancel">Cancel</button>
<button class="btn btn-sm btn-primary import-confirm">Import</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
const close = () => {
backdrop.classList.remove('is-visible');
setTimeout(() => backdrop.remove(), 200);
};
modal.querySelector('.modal-close').addEventListener('click', close);
modal.querySelector('.import-cancel').addEventListener('click', close);
modal.querySelector('.import-confirm').addEventListener('click', async () => {
const password = modal.querySelector('.import-password').value;
const strategy = modal.querySelector('.import-strategy').value;
const status = modal.querySelector('.import-status');
const confirmBtn = modal.querySelector('.import-confirm');
status.textContent = '';
if (!password) {
status.textContent = 'Please enter the decryption password.';
return;
}
try {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Importing...';
const count = await window.AlchementaryLink.wailsService.ImportProfilesEncrypted(password, strategy);
this.app.notifications.success(`Imported ${count} profile(s)`);
await this.app.loadProfiles();
close();
} catch (err) {
if (String(err).includes('cancelled')) {
return;
}
status.textContent = this._formatImportError(err);
this.app.notifications.error('Import failed');
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Import';
}
});
}
_formatImportError(err) {
const msg = String(err || 'Unknown import error');
return msg.replace(/\s+/g, ' ').trim();
}
}
// ---------------------------------------------------------------------------
// Utility helpers
// ---------------------------------------------------------------------------
function _esc(s) {
const el = document.createElement('span');
el.textContent = s || '';
return el.innerHTML;
}
function _formatSeconds(secs) {
if (!secs || secs <= 0) return '0:00';
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function _formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let val = bytes;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
// ---------------------------------------------------------------------------
// Export to global namespace
// ---------------------------------------------------------------------------
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.RecordingManager = RecordingManager;
window.AlchementaryLink.ExportImportUI = ExportImportUI;
})();
-373
View File
@@ -1,373 +0,0 @@
(function() {
/**
* Alchementary Link — secret/passphrase prompt dialogs.
* Provides modal prompts for collecting passwords, master-vault passphrases,
* and SSH host-key verification confirmations.
*/
const { createElement, escapeHtml } = window.AlchementaryLink;
class SecretPrompt {
constructor(app) {
/** @type {App} */
this.app = app;
/** @type {HTMLElement|null} Currently open overlay. */
this._overlay = null;
}
// ── Generic password prompt ──────────────────────────────────────
/**
* Displays a modal with a single password field and returns the entered value.
* Rejects if the user cancels.
* @param {string} title - Dialog heading.
* @param {string} message - Instructional text shown above the input.
* @returns {Promise<string>}
*/
prompt(title, message) {
return new Promise((resolve, reject) => {
this._close(); // dismiss any existing prompt
const overlay = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-secret');
// Header
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
// Body
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">${escapeHtml(message)}</p>
<div class="form-group">
<input type="password" class="form-input secret-input"
autocomplete="off" spellcheck="false" placeholder="Enter value…" />
</div>
`;
modal.appendChild(body);
// Footer
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const submitBtn = createElement('button', 'btn btn-primary', { textContent: 'Submit' });
footer.appendChild(cancelBtn);
footer.appendChild(submitBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
});
const input = modal.querySelector('.secret-input');
input.focus();
const handleSubmit = () => {
const value = input.value;
this._close();
resolve(value);
};
const handleCancel = () => {
this._close();
reject(new Error('User cancelled'));
};
submitBtn.addEventListener('click', handleSubmit);
cancelBtn.addEventListener('click', handleCancel);
header.querySelector('.modal-close').addEventListener('click', handleCancel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) handleCancel();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
});
});
}
// ── Master password (vault initialisation / unlock) ──────────────
/**
* Prompts for the master vault password.
* When `isNew` is true, a confirmation field is shown.
* @param {boolean} isNew - Whether this is the first time (create vault).
* @returns {Promise<string>} The master password.
*/
promptMasterPassword(isNew) {
return new Promise((resolve, reject) => {
this._close();
const overlay = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-secret');
const title = isNew ? 'Create Master Password' : 'Unlock Secret Store';
const description = isNew
? 'Choose a strong master password. This protects all saved credentials.'
: 'Enter your master password to unlock the secret store.';
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">${escapeHtml(title)}</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
let formHtml = `
<p class="secret-message">${escapeHtml(description)}</p>
<div class="form-group">
<label class="form-label">Master password</label>
<input type="password" class="form-input master-password"
autocomplete="off" spellcheck="false" placeholder="Password…" />
</div>
`;
if (isNew) {
formHtml += `
<div class="password-strength-meter">
<div class="strength-bar"><div class="strength-fill"></div></div>
<span class="strength-label"></span>
</div>
<div class="form-group">
<label class="form-label">Confirm password</label>
<input type="password" class="form-input master-confirm"
autocomplete="off" spellcheck="false" placeholder="Confirm…" />
</div>
<p class="form-hint secret-error" style="display:none;"></p>
`;
}
body.innerHTML = formHtml;
modal.appendChild(body);
const footer = createElement('div', 'modal-footer');
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
const submitBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Unlock' });
footer.appendChild(cancelBtn);
footer.appendChild(submitBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
});
const passInput = modal.querySelector('.master-password');
const confirmInput = modal.querySelector('.master-confirm');
const errorEl = modal.querySelector('.secret-error');
passInput.focus();
// Wire up the password strength meter for new vault creation.
if (isNew) {
const strengthFill = modal.querySelector('.strength-fill');
const strengthLabel = modal.querySelector('.strength-label');
passInput.addEventListener('input', () => {
const result = _measureStrength(passInput.value);
strengthFill.style.width = result.percent + '%';
strengthFill.className = 'strength-fill strength-' + result.level;
strengthLabel.textContent = result.label;
strengthLabel.className = 'strength-label strength-' + result.level;
});
}
const handleSubmit = () => {
const pw = passInput.value;
if (!pw) {
passInput.classList.add('input-error');
return;
}
if (isNew) {
const confirm = confirmInput.value;
if (pw !== confirm) {
errorEl.textContent = 'Passwords do not match.';
errorEl.style.display = 'block';
confirmInput.classList.add('input-error');
return;
}
}
this._close();
resolve(pw);
};
const handleCancel = () => {
this._close();
reject(new Error('User cancelled'));
};
submitBtn.addEventListener('click', handleSubmit);
cancelBtn.addEventListener('click', handleCancel);
header.querySelector('.modal-close').addEventListener('click', handleCancel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) handleCancel();
});
const handleKeydown = (e) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') handleCancel();
};
passInput.addEventListener('keydown', handleKeydown);
if (confirmInput) {
confirmInput.addEventListener('keydown', handleKeydown);
}
});
}
// ── SSH host-key verification ────────────────────────────────────
/**
* Asks the user whether to accept an SSH host key.
* @param {string} host
* @param {string} keyType - e.g. "ssh-ed25519"
* @param {string} fingerprint - SHA256 fingerprint
* @returns {Promise<boolean>} true = accept, false = reject.
*/
promptHostKeyVerification(host, keyType, fingerprint) {
return new Promise((resolve) => {
this._close();
const overlay = createElement('div', 'modal-backdrop');
const modal = createElement('div', 'modal modal-secret modal-hostkey');
const header = createElement('div', 'modal-header');
header.innerHTML = `
<h2 class="modal-title">Unknown Host Key</h2>
<button class="modal-close" aria-label="Close">&times;</button>
`;
modal.appendChild(header);
const body = createElement('div', 'modal-body');
body.innerHTML = `
<p class="secret-message">
The authenticity of host <strong>${escapeHtml(host)}</strong> cannot be established.
</p>
<div class="hostkey-details">
<div class="hostkey-row">
<span class="hostkey-label">Key type</span>
<code class="hostkey-value">${escapeHtml(keyType)}</code>
</div>
<div class="hostkey-row">
<span class="hostkey-label">Fingerprint</span>
<code class="hostkey-value">${escapeHtml(fingerprint)}</code>
</div>
</div>
<p class="secret-message">
Are you sure you want to continue connecting?
</p>
`;
modal.appendChild(body);
const footer = createElement('div', 'modal-footer');
const rejectBtn = createElement('button', 'btn btn-secondary', { textContent: 'Reject' });
const acceptBtn = createElement('button', 'btn btn-primary', { textContent: 'Accept & Save' });
footer.appendChild(rejectBtn);
footer.appendChild(acceptBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
requestAnimationFrame(() => {
overlay.classList.add('is-visible');
modal.classList.add('is-visible');
acceptBtn.focus();
});
acceptBtn.addEventListener('click', () => {
this._close();
resolve(true);
});
rejectBtn.addEventListener('click', () => {
this._close();
resolve(false);
});
header.querySelector('.modal-close').addEventListener('click', () => {
this._close();
resolve(false);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this._close();
resolve(false);
}
});
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this._close();
resolve(false);
}
});
});
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Closes and removes the current overlay, if any.
* @private
*/
_close() {
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
}
}
/**
* Estimates password strength based on length and character diversity.
* Returns an object with { percent, level, label } for the strength meter.
* @param {string} pw
* @returns {{ percent: number, level: string, label: string }}
* @private
*/
function _measureStrength(pw) {
if (!pw) return { percent: 0, level: 'none', label: '' };
let score = 0;
const len = pw.length;
// Length contribution (0–40 points)
score += Math.min(len * 3, 40);
// Character-class diversity (0–40 points)
const hasLower = /[a-z]/.test(pw);
const hasUpper = /[A-Z]/.test(pw);
const hasDigit = /[0-9]/.test(pw);
const hasSymbol = /[^a-zA-Z0-9]/.test(pw);
const classes = [hasLower, hasUpper, hasDigit, hasSymbol].filter(Boolean).length;
score += classes * 10;
// Unique-character ratio bonus (0–20 points)
const unique = new Set(pw).size;
score += Math.min(Math.floor((unique / Math.max(len, 1)) * 20), 20);
score = Math.min(score, 100);
if (score < 25) return { percent: score, level: 'weak', label: 'Weak' };
if (score < 50) return { percent: score, level: 'fair', label: 'Fair' };
if (score < 75) return { percent: score, level: 'good', label: 'Good' };
return { percent: score, level: 'strong', label: 'Strong' };
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.SecretPrompt = SecretPrompt;
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-939
View File
@@ -1,939 +0,0 @@
(function () {
/**
* Alchementary Link — sidebar module.
* Renders the profile list with search, sections (favourites, recent, all),
* and context menus.
*/
const {
createElement,
escapeHtml,
fuzzyMatch,
protocolColour,
dismissContextMenus,
truncate,
} = window.AlchementaryLink;
/** Maximum number of profiles shown in the "Recent" section. */
const RECENT_LIMIT = 5;
class Sidebar {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
this.el = document.getElementById("sidebar-profiles");
this.searchQuery = "";
/** @type {Set<string>} */
this.selectedProfiles = new Set();
// Section collapse state
this.collapsedSections = new Set();
// Dismiss context menus on any outside click
document.addEventListener("click", () => dismissContextMenus());
if (this.el) {
this.el.addEventListener("click", (e) => {
if (
!e.target.closest(".sidebar-item") &&
this.selectedProfiles.size > 0
) {
this._clearSelection();
}
});
}
}
// ── Public API ───────────────────────────────────────────────────
/**
* Full re-render of the sidebar profile list.
* Profiles are organised into:
* 1. Favourites (if any)
* 2. Recent (last 5 connected)
* 3. One section per group (sorted alphabetically)
* 4. Ungrouped (profiles with no group assigned)
*/
render() {
if (!this.el) return;
if (this.app.mode === "leviathan") {
this.app.leviathan.renderSidebar();
return;
}
this.el.innerHTML = "";
const profiles = this._filteredProfiles();
this._syncSelection(profiles);
if (this.selectedProfiles.size > 0) {
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) {
this._renderSection("★ Favourites", "favourites", favourites);
}
// Recent
const recent = profiles
.filter((p) => p.lastConnected)
.sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected))
.slice(0, RECENT_LIMIT);
if (recent.length > 0) {
this._renderSection("Recent", "recent", recent);
}
// ── Group-based sections ─────────────────────────────────────
// Collect distinct groups and sort them alphabetically.
const groupMap = new Map();
const ungrouped = [];
for (const p of profiles) {
const grp = (p.folder || "").trim();
if (grp) {
if (!groupMap.has(grp)) groupMap.set(grp, []);
groupMap.get(grp).push(p);
} else {
ungrouped.push(p);
}
}
// Render each group as its own collapsible section
const sortedGroups = [...groupMap.keys()].sort((a, b) =>
a.localeCompare(b),
);
for (const grp of sortedGroups) {
this._renderSection(grp, `group:${grp}`, groupMap.get(grp));
}
// Ungrouped connections
if (ungrouped.length > 0) {
const label = sortedGroups.length > 0 ? "Ungrouped" : "All Connections";
this._renderSection(label, "ungrouped", ungrouped);
}
// Empty state
if (profiles.length === 0) {
this.el.appendChild(this._renderEmptyState());
}
}
/**
* Filters the displayed profiles by query.
* @param {string} query
*/
search(query) {
this.searchQuery = query.trim();
this.render();
}
/**
* Highlights the sidebar item for the currently active profile.
* @param {string|null} profileId
*/
highlightActive(profileId) {
this.el.querySelectorAll(".sidebar-item").forEach((item) => {
item.classList.toggle(
"sidebar-item-active",
item.dataset.profileId === profileId,
);
});
}
/**
* Toggles the collapsed state of a named section.
* @param {string} sectionName
*/
toggleSection(sectionName) {
if (this.collapsedSections.has(sectionName)) {
this.collapsedSections.delete(sectionName);
} else {
this.collapsedSections.add(sectionName);
}
this.render();
}
// ── Rendering helpers ────────────────────────────────────────────
/**
* Renders the sidebar empty state for first-run and no-search-results cases.
* @returns {HTMLElement}
* @private
*/
_renderEmptyState() {
const isSearching = this.searchQuery.length > 0;
const empty = createElement("div", "sidebar-empty");
const icon = createElement("div", "sidebar-empty-icon", {
textContent: isSearching ? "⌕" : "+",
"aria-hidden": "true",
});
empty.appendChild(icon);
empty.appendChild(
createElement("div", "sidebar-empty-title", {
textContent: isSearching ? "No matching profiles" : "No saved profiles",
}),
);
empty.appendChild(
createElement("p", "sidebar-empty-text", {
textContent: isSearching
? "Try a different name, host, group, or protocol."
: "Create a profile for the servers and tools you use often.",
}),
);
if (!isSearching) {
const actions = createElement("div", "sidebar-empty-actions");
const addButton = createElement("button", "btn btn-primary btn-sm", {
type: "button",
textContent: "New Profile",
});
addButton.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.app.profileEditor.openNew();
});
actions.appendChild(addButton);
empty.appendChild(actions);
}
return empty;
}
/**
* Renders a collapsible section of profiles.
* @param {string} title
* @param {string} key
* @param {Array<Object>} profiles
* @private
*/
_renderSection(title, key, profiles) {
const section = createElement("div", "sidebar-section");
const isCollapsed = this.collapsedSections.has(key);
const isGroupSection = key.startsWith("group:");
// Section header
const header = createElement(
"div",
`sidebar-section-header ${isGroupSection ? "sidebar-section-header-group" : ""}`.trim(),
);
header.innerHTML = `
<span class="section-chevron ${isCollapsed ? "collapsed" : ""}">${isCollapsed ? "▸" : "▾"}</span>
<span class="section-title">${escapeHtml(title)}</span>
<span class="section-count">${profiles.length}</span>
`;
// Prevent mousedown from starting a text selection gesture in WKWebView,
// which would swallow the first click and require a double-click.
header.addEventListener("mousedown", (e) => e.preventDefault());
header.addEventListener("click", () => this.toggleSection(key));
section.appendChild(header);
// Items
if (!isCollapsed) {
const list = createElement("div", "sidebar-list");
for (const profile of profiles) {
list.appendChild(this.renderProfileItem(profile));
}
section.appendChild(list);
}
this.el.appendChild(section);
}
/**
* Creates a single sidebar list item for a profile.
* @param {Object} profile
* @returns {HTMLElement}
*/
renderProfileItem(profile) {
const item = createElement("div", "sidebar-item", {
dataset: { profileId: profile.id },
});
if (this.selectedProfiles.has(profile.id)) {
item.classList.add("sidebar-item-selected");
}
// Highlight if this profile's session is active
const activeSession = this.app.state.activeSessionId
? this.app.state.getSession(this.app.state.activeSessionId)
: null;
if (activeSession && activeSession.profileId === profile.id) {
item.classList.add("sidebar-item-active");
}
// Protocol icon
const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol);
const indicator = createElement(
"span",
`protocol-indicator ${protocolColour(profile.protocol)}`,
{
title: protocolLabel,
"aria-label": protocolLabel,
},
);
item.appendChild(indicator);
// Text content
const textWrapper = createElement("div", "sidebar-item-text");
const displayName = (profile.displayName || "").trim();
const primaryLabel = displayName || profile.host || "Untitled";
const nameEl = createElement("span", "sidebar-item-name", {
textContent: primaryLabel,
});
textWrapper.appendChild(nameEl);
const subtitle = protocolLabel;
const subEl = createElement("span", "sidebar-item-host", {
textContent: truncate(subtitle, 30),
});
textWrapper.appendChild(subEl);
item.appendChild(textWrapper);
// Edit button (visible on hover)
const editBtn = createElement("button", "sidebar-item-edit", {
textContent: "✎",
title: "Edit profile",
type: "button",
"aria-label": "Edit profile",
});
let editTriggered = false;
const triggerEdit = (e) => {
e.preventDefault();
e.stopPropagation();
if (editTriggered) return;
editTriggered = true;
requestAnimationFrame(() => {
editTriggered = false;
});
this.app.profileEditor.openEdit(profile.id);
};
editBtn.addEventListener("mousedown", triggerEdit);
editBtn.addEventListener("click", triggerEdit);
item.appendChild(editBtn);
// Favourite star
if (profile.favourite) {
const star = createElement("span", "sidebar-item-star is-favourite", {
textContent: "★",
});
item.appendChild(star);
}
// Prevent mousedown text selection in WKWebView, but allow real controls.
item.addEventListener("mousedown", (e) => {
if (e.target.closest("button, input, select, textarea")) return;
e.preventDefault();
});
// Click → connect / multi-select
item.addEventListener("click", (e) => {
const multi = e.metaKey || e.ctrlKey;
if (multi) {
e.preventDefault();
this._toggleSelection(profile.id);
return;
}
if (this.selectedProfiles.size > 0) {
this._clearSelection();
}
this.app.connectProfile(profile.id);
});
// Double-click → edit
item.addEventListener("dblclick", (e) => {
e.stopPropagation();
this.app.profileEditor.openEdit(profile.id);
});
// Right-click → context menu
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
this.renderContextMenu(profile, e.clientX, e.clientY);
});
return item;
}
/**
* Shows a right-click context menu for a profile.
* @param {Object} profile
* @param {number} x - Viewport X.
* @param {number} y - Viewport Y.
*/
renderContextMenu(profile, x, y) {
dismissContextMenus();
const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const items = [
{ label: "Connect", action: () => this.app.connectProfile(profile.id) },
{
label: "Edit",
action: () => this.app.profileEditor.openEdit(profile.id),
},
{ label: "Duplicate", action: () => this._duplicateProfile(profile) },
{ divider: true },
{
label: profile.favourite
? "Remove from Favourites"
: "Add to Favourites",
action: () => this._toggleFavourite(profile),
},
{ divider: true },
{
label: "Delete",
className: "context-menu-danger",
action: () => this._deleteProfile(profile),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement("div", "context-menu-divider"));
continue;
}
const btn = createElement(
"button",
`context-menu-item ${entry.className || ""}`,
{
textContent: entry.label,
},
);
btn.addEventListener("click", () => {
dismissContextMenus();
entry.action();
});
menu.appendChild(btn);
}
// Keep menu within viewport
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
}
if (rect.bottom > window.innerHeight) {
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
}
}
// ── Actions ──────────────────────────────────────────────────────
/**
* Toggles the favourite status of a profile.
* @param {Object} profile
* @private
*/
async _toggleFavourite(profile) {
try {
const newVal = !profile.favourite;
await window.AlchementaryLink.wailsService.SetFavourite(profile.id, newVal);
profile.favourite = newVal;
this.app.state.updateProfile(profile);
this.render();
} catch (err) {
this.app.notifications.error("Failed to update favourite: " + err);
}
}
/**
* Duplicates a profile.
* @param {Object} profile
* @private
*/
async _duplicateProfile(profile) {
try {
const dup = await window.AlchementaryLink.wailsService.DuplicateProfile(profile.id);
this.app.state.addProfile(dup);
this.render();
this.app.notifications.success(`Duplicated "${profile.displayName}"`);
} catch (err) {
this.app.notifications.error("Failed to duplicate profile: " + err);
}
}
/**
* Deletes a profile after confirmation.
* @param {Object} profile
* @private
*/
async _deleteProfile(profile) {
// Use a custom confirmation modal — window.confirm() is silently
// blocked in WKWebView and always returns false.
const ok = await this._confirmDelete(profile);
if (!ok) return;
try {
await window.AlchementaryLink.wailsService.DeleteProfile(profile.id);
this.app.state.removeProfile(profile.id);
this.render();
this.app.notifications.success("Profile deleted");
} catch (err) {
this.app.notifications.error("Failed to delete profile: " + err);
}
}
/**
* Shows a confirmation modal for profile deletion.
* @param {Object} profile
* @returns {Promise<boolean>}
* @private
*/
_confirmDelete(profile) {
return new Promise((resolve) => {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-sm");
const label = escapeHtml(
profile.displayName || profile.host || "Untitled",
);
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Delete Profile</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Delete <strong>${label}</strong>? This cannot be undone.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">Delete</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);
});
});
}
// ── Multi-select + bulk actions ─────────────────────────────────
/**
* Removes selections that are no longer visible in the filtered list.
* @param {Array<Object>} profiles
* @private
*/
_syncSelection(profiles) {
const valid = new Set(profiles.map((p) => p.id));
for (const id of [...this.selectedProfiles]) {
if (!valid.has(id)) this.selectedProfiles.delete(id);
}
}
/** @private */
_toggleSelection(profileId) {
if (this.selectedProfiles.has(profileId)) {
this.selectedProfiles.delete(profileId);
} else {
this.selectedProfiles.add(profileId);
}
this.render();
}
/** @private */
_clearSelection() {
if (this.selectedProfiles.size === 0) return;
this.selectedProfiles.clear();
this.render();
}
/** @private */
_getSelectedProfiles() {
return this.app.state.profiles.filter((p) =>
this.selectedProfiles.has(p.id),
);
}
/** @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");
const count = createElement("span", "sidebar-bulk-count", {
textContent: `${this.selectedProfiles.size} selected`,
});
bar.appendChild(count);
const actions = createElement("div", "sidebar-bulk-buttons");
const makeBtn = (label, handler, className) => {
const btn = createElement(
"button",
`btn btn-xs ${className || ""}`.trim(),
{
textContent: label,
},
);
btn.addEventListener("click", handler);
actions.appendChild(btn);
};
makeBtn("Connect", () => this._bulkConnect());
makeBtn("Disconnect", () => this._bulkDisconnect(), "btn-outline");
makeBtn("Tags", () => this._bulkEditTags(), "btn-outline");
makeBtn("Move Group", () => this._bulkMoveGroup(), "btn-outline");
makeBtn("Clear", () => this._clearSelection(), "btn-ghost");
bar.appendChild(actions);
return bar;
}
/** @private */
_bulkConnect() {
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
this.app.connectProfile(p.id);
}
this._clearSelection();
}
/** @private */
async _bulkDisconnect() {
const sessions = this.app.state
.getAllSessions()
.filter((s) => this.selectedProfiles.has(s.profileId));
if (sessions.length === 0) {
this.app.notifications.info("No active sessions to disconnect");
return;
}
const ok = await this._confirmBulkDisconnect(sessions.length);
if (!ok) return;
for (const s of sessions) {
await this.app.closeSessionImmediate(s.id);
}
this._clearSelection();
}
/** @private */
async _bulkEditTags() {
const result = await this._promptTagEdit();
if (!result) return;
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
const updated = JSON.parse(JSON.stringify(p));
const existing = updated.tags || [];
if (result.mode === "replace") {
updated.tags = result.tags;
} else {
const merged = new Set([...existing, ...result.tags]);
updated.tags = [...merged];
}
try {
const saved = await window.AlchementaryLink.wailsService.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error("Failed to update tags: " + err);
}
}
this.render();
this._clearSelection();
}
/** @private */
async _bulkMoveGroup() {
const folder = await this._promptGroupMove();
if (folder === null) return;
const profiles = this._getSelectedProfiles();
for (const p of profiles) {
const updated = JSON.parse(JSON.stringify(p));
updated.folder = folder;
try {
const saved = await window.AlchementaryLink.wailsService.UpdateProfile(updated);
this.app.state.updateProfile(saved);
} catch (err) {
this.app.notifications.error("Failed to move group: " + err);
}
}
this.render();
this._clearSelection();
}
/** @private */
_confirmBulkDisconnect(count) {
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">Disconnect Sessions</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Disconnect ${count} active ${count === 1 ? "session" : "sessions"}?
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</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);
});
});
}
/** @private */
_promptTagEdit() {
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">Edit Tags</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Tags (comma separated)</label>
<input class="form-input tag-input" type="text" placeholder="prod, linux, db" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-outline confirm-add">Add Tags</button>
<button class="btn btn-sm btn-primary confirm-replace">Replace Tags</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector(".tag-input");
const parseTags = () => {
const raw = (input.value || "").split(",");
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
};
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(null));
modal
.querySelector(".confirm-cancel")
.addEventListener("click", () => close(null));
modal.querySelector(".confirm-add").addEventListener("click", () => {
close({ mode: "add", tags: parseTags() });
});
modal
.querySelector(".confirm-replace")
.addEventListener("click", () => {
close({ mode: "replace", tags: parseTags() });
});
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null);
});
setTimeout(() => input.focus(), 50);
});
}
/** @private */
_promptGroupMove() {
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">Move to Group</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Group name</label>
<input class="form-input group-input" type="text" placeholder="e.g. Production" />
<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:6px;">
Leave blank to remove the group.
</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-primary confirm-ok">Move</button>
</div>
`;
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
const input = modal.querySelector(".group-input");
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(null));
modal
.querySelector(".confirm-cancel")
.addEventListener("click", () => close(null));
modal
.querySelector(".confirm-ok")
.addEventListener("click", () => close(input.value.trim()));
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close(null);
});
setTimeout(() => input.focus(), 50);
});
}
// ── Filtering ────────────────────────────────────────────────────
/**
* Returns profiles filtered by the current search query.
* @returns {Array<Object>}
* @private
*/
_filteredProfiles() {
if (!this.searchQuery) return this.app.state.profiles;
return this.app.state.profiles
.map((p) => {
// Match against display name, host, tags, and protocol
const fields = [
p.displayName || "",
p.host || "",
(p.tags || []).join(" "),
p.protocol || "",
].join(" ");
const result = fuzzyMatch(fields, this.searchQuery);
return { profile: p, ...result };
})
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.map((r) => r.profile);
}
/**
* Returns a display label for a protocol.
* @param {string} protocol
* @returns {string}
* @private
*/
_protocolLabel(protocol) {
switch (protocol) {
case "github":
return "GitHub";
case "ssh":
return "SSH";
case "telnet":
return "TELNET";
case "raw":
return "RAW";
case "irc":
return "IRC";
default:
return (protocol || "").toUpperCase() || "UNKNOWN";
}
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.Sidebar = Sidebar;
})();
-454
View File
@@ -1,454 +0,0 @@
(function () {
/**
* Alchementary Link — snippet / command library.
* Provides a panel for managing and sending reusable commands to active sessions.
*/
const { createElement, escapeHtml, dismissContextMenus } =
window.AlchementaryLink;
class SnippetLibrary {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {Array<Object>} */
this.snippets = [];
this.searchQuery = "";
}
// ── Public API ───────────────────────────────────────────────────
/**
* Opens the snippet library modal.
*/
async open() {
await this._loadSnippets();
this._showModal();
}
/**
* 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");
return;
}
const session = this.app.state.getSession(sessionId);
if (!session || session.state !== "connected") {
this.app.notifications.error("Session is not connected");
return;
}
// 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 = window.AlchementaryLink.utf8ToBase64(command + "\n");
window.AlchementaryLink.wailsService.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">&times;</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 ─────────────────────────────────────────────────────────
/** @private */
async _loadSnippets() {
try {
this.snippets = (await window.AlchementaryLink.wailsService.ListSnippets()) || [];
} catch (err) {
this.snippets = [];
console.error("Failed to load snippets:", err);
}
}
// ── Modal ────────────────────────────────────────────────────────
/** @private */
_showModal() {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", "modal modal-lg");
modal.innerHTML = `
<div class="modal-header">
<h3 class="modal-title">Command Library</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body snippet-body" style="display:flex;gap:var(--space-3);height:400px;">
<div class="snippet-list-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<div style="display:flex;gap:var(--space-2);">
<input type="text" class="form-input snippet-search" placeholder="Search snippets…" style="flex:1;" />
<button class="btn btn-sm btn-primary snippet-add-btn">+ New</button>
</div>
<div class="snippet-list" style="flex:1;overflow-y:auto;"></div>
</div>
<div class="snippet-detail-panel" style="flex:1;display:flex;flex-direction:column;gap:var(--space-2);overflow:hidden;">
<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>
</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();
});
// Search
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", () => {
this._showEditor(modal, null, close);
});
this._renderList(modal, close);
}
/** @private */
_renderList(modal, closeFn) {
const listEl = modal.querySelector(".snippet-list");
if (!listEl) return;
listEl.innerHTML = "";
const filtered = this.snippets.filter((sn) => {
if (!this.searchQuery) return true;
return (
sn.name.toLowerCase().includes(this.searchQuery) ||
sn.command.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>';
return;
}
for (const sn of filtered) {
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.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 = "";
});
listEl.appendChild(item);
}
}
/** @private */
_showDetail(modal, snippet, closeFn) {
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
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", "", {
textContent: snippet.description,
});
descEl.style.cssText =
"font-size:var(--text-sm);color:var(--text-secondary);";
detailPanel.appendChild(descEl);
}
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;";
detailPanel.appendChild(cmdEl);
// Action buttons
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", () => {
this.sendToActiveSession(snippet.command);
});
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 () => {
try {
await window.AlchementaryLink.wailsService.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>';
} catch (err) {
this.app.notifications.error("Failed to delete: " + err);
}
});
actions.appendChild(sendBtn);
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
detailPanel.appendChild(actions);
}
/** @private */
_showEditor(modal, snippet, closeFn) {
const detailPanel = modal.querySelector(".snippet-detail-panel");
if (!detailPanel) return;
detailPanel.innerHTML = "";
const isNew = !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)}" />
</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 || "")}" />
</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>
</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" />
</div>
<div style="display:flex;gap:var(--space-2);">
<button class="btn btn-sm btn-primary snippet-save-btn">Save</button>
<button class="btn btn-sm snippet-cancel-btn">Cancel</button>
</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();
if (!name) {
this.app.notifications.error("Name is required");
return;
}
if (!command) {
this.app.notifications.error("Command is required");
return;
}
try {
const data = {
id: isNew ? "" : snippet.id,
name,
command,
description,
folder,
tags: isNew ? [] : snippet.tags || [],
};
let saved;
if (isNew) {
saved = await window.AlchementaryLink.wailsService.CreateSnippet(data);
this.snippets.push(saved);
} else {
saved = await window.AlchementaryLink.wailsService.UpdateSnippet(data);
const idx = this.snippets.findIndex((s) => s.id === saved.id);
if (idx !== -1) this.snippets[idx] = saved;
}
this._renderList(modal, closeFn);
this._showDetail(modal, saved, closeFn);
this.app.notifications.info(
isNew ? "Snippet created" : "Snippet updated",
);
} catch (err) {
this.app.notifications.error("Failed to save snippet: " + err);
}
});
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>';
}
});
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.SnippetLibrary = SnippetLibrary;
})();
-797
View File
@@ -1,797 +0,0 @@
(function() {
/**
* Alchementary Link — Split Pane Manager.
* Enables splitting session panels into multiple panes, each hosting
* its own terminal session. Supports horizontal (top/bottom) and
* vertical (left/right) splits with draggable resize dividers.
*/
var MIN_PANE_SIZE = 100; // px
/**
* Unique pane id counter.
* @type {number}
*/
var _paneIdCounter = 0;
function nextPaneId() {
return 'pane-' + (++_paneIdCounter);
}
class SplitPaneManager {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/**
* Map of sessionId → split tree root (or null if unsplit).
* The tree is a simple object:
* Leaf: { type: 'leaf', paneId, el, sessionId }
* Branch: { type: 'branch', direction, first, second, el, divider, ratio }
* @type {Map<string, Object|null>}
*/
this.trees = new Map();
/**
* Connections started from placeholders but not yet attached to a pane.
* Closing a pane/tab marks its token cancelled so a late backend result
* is disconnected instead of becoming an invisible session.
* @type {Map<string, {ownerSessionId: string, cancelled: boolean, completed: boolean}>}
*/
this.pendingConnections = new Map();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Splits the active session panel vertically (left | right).
*/
splitVertical() {
this._splitActive('vertical');
}
/**
* Splits the active session panel horizontally (top / bottom).
*/
splitHorizontal() {
this._splitActive('horizontal');
}
/**
* Removes all split state for a session (used on tab close).
* @param {string} sessionId
*/
async cleanup(sessionId) {
var root = this.trees.get(sessionId);
this.trees.delete(sessionId);
var childSessionIds = new Set();
var self = this;
function releaseNode(node) {
if (!node) return;
if (node.type === 'leaf') {
self._cancelPendingConnection(node.paneId);
if (node.sessionId && node.sessionId !== sessionId) {
childSessionIds.add(node.sessionId);
}
if (node.el) node.el._splitNode = null;
node.parent = null;
return;
}
if (node.cancelDrag) node.cancelDrag();
releaseNode(node.first);
releaseNode(node.second);
if (node.el) node.el._splitNode = null;
node.parent = null;
}
releaseNode(root);
// A connection may still be resolving before its leaf has a session ID.
// Its token remains captured by the promise and will disconnect any late
// result after _cancelPendingConnection marks it cancelled.
for (var [paneId, token] of this.pendingConnections) {
if (token.ownerSessionId === sessionId) {
token.cancelled = true;
this.pendingConnections.delete(paneId);
}
}
await Promise.allSettled(Array.from(childSessionIds, function(childId) {
return self.app.closeSessionImmediate(childId, {
removeTab: false,
cleanupSplits: false
});
}));
}
/**
* Re-fits all terminals visible in the active session's pane tree.
*/
refitAll() {
var activeId = this.app.state.activeSessionId;
if (!activeId) return;
var tree = this.trees.get(activeId);
if (tree) this._refitTree(tree);
}
// ── Core split logic ─────────────────────────────────────────────
/**
* Splits the active session panel.
* @param {'horizontal'|'vertical'} direction
* @private
*/
_splitActive(direction) {
var sessionId = this.app.state.activeSessionId;
if (!sessionId) return;
var panel = document.getElementById('panel-' + sessionId);
if (!panel) return;
var tree = this.trees.get(sessionId);
if (!tree) {
// First split — wrap existing content
tree = this._initTree(panel, sessionId);
}
// Find the focused leaf (or the first leaf)
var leaf = this._findFocusedLeaf(tree) || this._firstLeaf(tree);
if (!leaf) return;
this._splitLeaf(leaf, direction, sessionId);
}
/**
* Creates the initial tree structure from an unsplit panel.
* Wraps the existing terminal-container in a split-pane leaf.
* @param {HTMLElement} panel
* @param {string} sessionId
* @returns {Object} root leaf node
* @private
*/
_initTree(panel, sessionId) {
var termContainer = panel.querySelector('.terminal-container');
// For non-terminal panels (raw, github), wrap whatever content exists
var contentChildren = Array.from(panel.children);
var paneId = nextPaneId();
var paneEl = document.createElement('div');
paneEl.className = 'split-pane';
paneEl.dataset.paneId = paneId;
paneEl.style.flex = '1';
// Move existing content into the pane
contentChildren.forEach(function(child) {
paneEl.appendChild(child);
});
// Reset terminal-container from absolute to relative positioning inside a pane
if (termContainer) {
termContainer.style.position = 'relative';
termContainer.style.inset = 'auto';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
}
panel.appendChild(paneEl);
var leaf = {
type: 'leaf',
paneId: paneId,
el: paneEl,
sessionId: sessionId,
parent: null
};
paneEl._splitNode = leaf;
this.trees.set(sessionId, leaf);
// Add focus tracking
this._addFocusTracking(paneEl);
return leaf;
}
/**
* Splits a leaf node into two panes.
* @param {Object} leaf - The leaf node to split.
* @param {'horizontal'|'vertical'} direction
* @param {string} ownerSessionId - The session that owns this panel.
* @private
*/
_splitLeaf(leaf, direction, ownerSessionId) {
var parentBranch = leaf.parent;
var panel = this._findPanel(ownerSessionId);
if (!panel) return;
// Create a container for the two panes
var containerEl = document.createElement('div');
containerEl.className = 'split-container split-' + direction;
containerEl.style.flex = leaf.el.style.flex || '1';
// The existing leaf becomes the first child
leaf.el.style.flex = '1';
// Create divider
var divider = document.createElement('div');
divider.className = 'split-divider';
// Create new empty pane
var newPaneId = nextPaneId();
var newPaneEl = document.createElement('div');
newPaneEl.className = 'split-pane';
newPaneEl.dataset.paneId = newPaneId;
newPaneEl.style.flex = '1';
// Add a placeholder with session picker to the new pane
this._renderPlaceholder(newPaneEl, ownerSessionId);
// Add close buttons to both panes
this._addCloseButton(leaf.el, ownerSessionId);
this._addCloseButton(newPaneEl, ownerSessionId);
// Add focus tracking
this._addFocusTracking(newPaneEl);
// Build the new branch node
var newLeaf = {
type: 'leaf',
paneId: newPaneId,
el: newPaneEl,
sessionId: null,
parent: null
};
newPaneEl._splitNode = newLeaf;
var branch = {
type: 'branch',
direction: direction,
first: leaf,
second: newLeaf,
el: containerEl,
divider: divider,
ratio: 0.5,
parent: parentBranch
};
leaf.parent = branch;
newLeaf.parent = branch;
containerEl._splitNode = branch;
// Replace the leaf element in the DOM
var leafParent = leaf.el.parentNode;
leafParent.insertBefore(containerEl, leaf.el);
containerEl.appendChild(leaf.el);
containerEl.appendChild(divider);
containerEl.appendChild(newPaneEl);
// Update the tree structure
if (parentBranch) {
if (parentBranch.first === leaf) {
parentBranch.first = branch;
} else {
parentBranch.second = branch;
}
} else {
// This was the root leaf — the branch becomes the new root
this.trees.set(ownerSessionId, branch);
}
// Set up divider dragging
this._setupDividerDrag(divider, branch);
// Refit terminals after layout change
var self = this;
requestAnimationFrame(function() {
self._refitTree(self.trees.get(ownerSessionId));
});
}
// ── Pane closing ─────────────────────────────────────────────────
/**
* Closes a pane and unsplits, keeping the sibling.
* @param {string} paneId
* @param {string} ownerSessionId
* @private
*/
_closePane(paneId, ownerSessionId) {
var root = this.trees.get(ownerSessionId);
if (!root) return;
var leaf = this._findLeafById(root, paneId);
if (!leaf || !leaf.parent) {
// Only one pane left — can't close it
return;
}
var branch = leaf.parent;
var sibling = (branch.first === leaf) ? branch.second : branch.first;
this._cancelPendingConnection(leaf.paneId);
// If the closing pane has a child session, release both its frontend
// renderer and backend connection. The owner pane cannot be closed
// independently because it provides the tab's identity.
if (leaf.sessionId && leaf.sessionId !== ownerSessionId) {
this.app.closeSessionImmediate(leaf.sessionId, {
removeTab: false,
cleanupSplits: false
});
leaf.sessionId = null;
}
if (branch.cancelDrag) branch.cancelDrag();
// Replace the branch element with the sibling element in the DOM
sibling.el.style.flex = branch.el.style.flex || '1';
branch.el.parentNode.insertBefore(sibling.el, branch.el);
branch.el.remove();
leaf.el._splitNode = null;
branch.el._splitNode = null;
// Remove close button from sibling if it's now the only pane
sibling.parent = branch.parent;
if (branch.parent) {
if (branch.parent.first === branch) {
branch.parent.first = sibling;
} else {
branch.parent.second = sibling;
}
} else {
// Sibling is now root
this.trees.set(ownerSessionId, sibling);
}
// Remove close button if we're back to a single leaf
var newRoot = this.trees.get(ownerSessionId);
if (newRoot && newRoot.type === 'leaf') {
var closeBtn = newRoot.el.querySelector(':scope > .split-pane-close');
if (closeBtn) closeBtn.remove();
}
// Refit terminals
var self = this;
requestAnimationFrame(function() {
self._refitTree(self.trees.get(ownerSessionId));
});
}
// ── Divider drag ─────────────────────────────────────────────────
/**
* Sets up mouse drag behaviour on a divider.
* @param {HTMLElement} divider
* @param {Object} branch
* @private
*/
_setupDividerDrag(divider, branch) {
var self = this;
var onMouseMove = null;
var onMouseUp = null;
var bodyClass = null;
function stopDrag(refit) {
if (bodyClass) document.body.classList.remove(bodyClass);
divider.classList.remove('is-dragging');
if (onMouseMove) document.removeEventListener('mousemove', onMouseMove);
if (onMouseUp) document.removeEventListener('mouseup', onMouseUp);
onMouseMove = null;
onMouseUp = null;
bodyClass = null;
if (refit) self._refitTree(branch);
}
branch.cancelDrag = function() {
stopDrag(false);
};
divider.addEventListener('mousedown', function(e) {
e.preventDefault();
stopDrag(false);
var isHorizontal = branch.direction === 'horizontal';
bodyClass = isHorizontal ? 'split-resizing-row' : 'split-resizing';
document.body.classList.add(bodyClass);
divider.classList.add('is-dragging');
var containerRect = branch.el.getBoundingClientRect();
var dividerSize = isHorizontal ? 4 : 4; // divider thickness
onMouseMove = function(e) {
var totalSize, mousePos;
if (isHorizontal) {
totalSize = containerRect.height - dividerSize;
mousePos = e.clientY - containerRect.top;
} else {
totalSize = containerRect.width - dividerSize;
mousePos = e.clientX - containerRect.left;
}
var ratio = mousePos / (totalSize + dividerSize);
ratio = Math.max(MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
ratio = Math.min(1 - MIN_PANE_SIZE / (totalSize + dividerSize), ratio);
branch.ratio = ratio;
branch.first.el.style.flex = ratio + '';
branch.second.el.style.flex = (1 - ratio) + '';
// Refit terminals in this branch
self._refitTree(branch);
};
onMouseUp = function() {
stopDrag(true);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
// ── Placeholder / session picker ─────────────────────────────────
/**
* Renders a placeholder with a list of available profiles to connect.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_renderPlaceholder(paneEl, ownerSessionId) {
var placeholder = document.createElement('div');
placeholder.className = 'split-pane-placeholder';
var icon = document.createElement('div');
icon.className = 'split-pane-placeholder-icon';
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>';
var text = document.createElement('div');
text.className = 'split-pane-placeholder-text';
text.textContent = 'Select a connection for this pane';
placeholder.appendChild(icon);
placeholder.appendChild(text);
// Build profile list
var profiles = this.app.state.profiles || [];
var list = document.createElement('div');
list.className = 'split-pane-profile-list';
var self = this;
var localBtn = document.createElement('button');
localBtn.className = 'split-pane-profile-btn';
var localBadge = document.createElement('span');
localBadge.className = 'protocol-badge';
localBadge.textContent = 'LOCAL';
localBadge.style.color = self._protocolColor('local');
var localLabel = document.createElement('span');
localLabel.textContent = 'Local Machine';
localBtn.appendChild(localBadge);
localBtn.appendChild(localLabel);
localBtn.addEventListener('click', function() {
self._connectLocalInPane(paneEl, ownerSessionId);
});
list.appendChild(localBtn);
profiles.forEach(function(profile) {
var btn = document.createElement('button');
btn.className = 'split-pane-profile-btn';
var badge = document.createElement('span');
badge.className = 'protocol-badge';
badge.textContent = (profile.protocol || 'ssh').toUpperCase();
badge.style.color = self._protocolColor(profile.protocol);
var label = document.createElement('span');
label.textContent = profile.displayName || profile.host || profile.id;
btn.appendChild(badge);
btn.appendChild(label);
btn.addEventListener('click', function() {
self._connectInPane(paneEl, profile, ownerSessionId);
});
list.appendChild(btn);
});
placeholder.appendChild(list);
paneEl.appendChild(placeholder);
}
/**
* Connects a profile in a specific pane.
* @param {HTMLElement} paneEl
* @param {Object} profile
* @param {string} ownerSessionId
* @private
*/
_connectInPane(paneEl, profile, ownerSessionId) {
var self = this;
var token = this._beginPendingConnection(paneEl, ownerSessionId);
// Remove placeholder
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
// Connect via backend
window.AlchementaryLink.wailsService.ConnectSession(profile.id).then(async function(sessionInfo) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
try {
await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id);
} catch (_) {
// The backend may already have completed the close.
}
return;
}
self._attachTerminalToPane(paneEl, sessionInfo, profile);
self.app.notifications.info('Connecting to ' + (profile.displayName || profile.host) + '…');
}).catch(function(err) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
return;
}
self.app.notifications.error('Connection failed: ' + err);
// Re-render placeholder on failure
self._renderPlaceholder(paneEl, ownerSessionId);
});
}
/**
* Opens a local shell in a specific pane.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_connectLocalInPane(paneEl, ownerSessionId) {
var self = this;
var token = this._beginPendingConnection(paneEl, ownerSessionId);
var placeholder = paneEl.querySelector('.split-pane-placeholder');
if (placeholder) placeholder.remove();
window.AlchementaryLink.wailsService.ConnectLocal().then(async function(sessionInfo) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
try {
await window.AlchementaryLink.wailsService.DisconnectSession(sessionInfo.id);
} catch (_) {
// The backend may already have completed the close.
}
return;
}
var prefs = self.app.state.preferences || {};
self._attachTerminalToPane(paneEl, sessionInfo, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle
});
self.app.notifications.info('Opening local terminal…');
}).catch(function(err) {
if (!self._completePendingConnection(paneEl, ownerSessionId, token)) {
return;
}
self.app.notifications.error('Local terminal failed: ' + err);
self._renderPlaceholder(paneEl, ownerSessionId);
});
}
/**
* Attaches a connected terminal session to a split pane.
* @param {HTMLElement} paneEl
* @param {Object} sessionInfo
* @param {Object} terminalProfile
* @private
*/
_attachTerminalToPane(paneEl, sessionInfo, terminalProfile) {
this.app.state.addSession(sessionInfo);
var termContainer = document.createElement('div');
termContainer.className = 'terminal-container';
termContainer.id = 'split-term-' + sessionInfo.id;
termContainer.style.position = 'relative';
termContainer.style.flex = '1';
termContainer.style.width = '100%';
termContainer.style.height = '100%';
paneEl.appendChild(termContainer);
var node = paneEl._splitNode;
if (node) {
node.sessionId = sessionInfo.id;
}
this.app.terminals.createTerminal(sessionInfo.id, termContainer.id, terminalProfile);
}
/**
* Tracks an asynchronous connection attempt owned by a pane.
* @private
*/
_beginPendingConnection(paneEl, ownerSessionId) {
var paneId = paneEl.dataset.paneId;
this._cancelPendingConnection(paneId);
var token = {
ownerSessionId: ownerSessionId,
cancelled: false,
completed: false
};
this.pendingConnections.set(paneId, token);
return token;
}
/**
* Returns true only if a connection result still belongs to a live pane.
* @private
*/
_completePendingConnection(paneEl, ownerSessionId, token) {
if (token.completed) return false;
token.completed = true;
var paneId = paneEl.dataset.paneId;
if (this.pendingConnections.get(paneId) === token) {
this.pendingConnections.delete(paneId);
}
var root = this.trees.get(ownerSessionId);
var leaf = this._findLeafById(root, paneId);
return !token.cancelled &&
paneEl.isConnected !== false &&
leaf &&
leaf.el === paneEl;
}
/**
* Cancels a pending pane connection without waiting for the backend call.
* A late successful result will disconnect itself.
* @private
*/
_cancelPendingConnection(paneId) {
var token = this.pendingConnections.get(paneId);
if (!token) return;
token.cancelled = true;
this.pendingConnections.delete(paneId);
}
// ── Tree traversal helpers ───────────────────────────────────────
/**
* Finds a leaf by paneId in the tree.
* @param {Object} node
* @param {string} paneId
* @returns {Object|null}
* @private
*/
_findLeafById(node, paneId) {
if (!node) return null;
if (node.type === 'leaf') {
return node.paneId === paneId ? node : null;
}
return this._findLeafById(node.first, paneId) || this._findLeafById(node.second, paneId);
}
/**
* Returns the first leaf node in the tree.
* @param {Object} node
* @returns {Object|null}
* @private
*/
_firstLeaf(node) {
if (!node) return null;
if (node.type === 'leaf') return node;
return this._firstLeaf(node.first);
}
/**
* Returns the focused leaf node (one whose pane has .is-focused).
* @param {Object} node
* @returns {Object|null}
* @private
*/
_findFocusedLeaf(node) {
if (!node) return null;
if (node.type === 'leaf') {
return node.el.classList.contains('is-focused') ? node : null;
}
return this._findFocusedLeaf(node.first) || this._findFocusedLeaf(node.second);
}
/**
* Recursively refits all terminals in a tree node.
* @param {Object} node
* @private
*/
_refitTree(node) {
if (!node) return;
if (node.type === 'leaf') {
if (node.sessionId) {
this.app.terminals.fitSession(node.sessionId);
}
return;
}
this._refitTree(node.first);
this._refitTree(node.second);
}
/**
* Finds the panel element for a session.
* @param {string} sessionId
* @returns {HTMLElement|null}
* @private
*/
_findPanel(sessionId) {
return document.getElementById('panel-' + sessionId);
}
// ── UI helpers ───────────────────────────────────────────────────
/**
* Adds a close button to a pane element.
* @param {HTMLElement} paneEl
* @param {string} ownerSessionId
* @private
*/
_addCloseButton(paneEl, ownerSessionId) {
// Don't add if already present
if (paneEl.querySelector(':scope > .split-pane-close')) return;
// The owner session supplies the tab/panel identity. Allowing that leaf
// to close would leave an invisible backend session behind.
if (paneEl._splitNode && paneEl._splitNode.sessionId === ownerSessionId) return;
var btn = document.createElement('button');
btn.className = 'split-pane-close';
btn.textContent = '✕';
btn.title = 'Close pane';
var self = this;
btn.addEventListener('click', function(e) {
e.stopPropagation();
self._closePane(paneEl.dataset.paneId, ownerSessionId);
});
paneEl.appendChild(btn);
}
/**
* Adds focus tracking to a pane — marks it as focused when clicked.
* @param {HTMLElement} paneEl
* @private
*/
_addFocusTracking(paneEl) {
paneEl.addEventListener('mousedown', function() {
// Remove focus from all sibling panes
var allPanes = paneEl.closest('.session-panel');
if (allPanes) {
allPanes.querySelectorAll('.split-pane.is-focused').forEach(function(el) {
el.classList.remove('is-focused');
});
}
paneEl.classList.add('is-focused');
});
}
/**
* Returns a colour for a protocol badge.
* @param {string} protocol
* @returns {string}
* @private
*/
_protocolColor(protocol) {
var colors = {
ssh: '#4ecdc4',
telnet: '#ffa726',
raw: '#ab47bc',
github: '#7c8aff',
local: '#66bb6a'
};
return colors[protocol] || '#4ecdc4';
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.SplitPaneManager = SplitPaneManager;
})();
-337
View File
@@ -1,337 +0,0 @@
(function () {
/**
* Alchementary Link — SSH Key Manager.
* Panel for listing, generating, copying, and deleting SSH key pairs.
*/
const { createElement, dismissContextMenus } = window.AlchementaryLink;
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">&times;</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.AlchementaryLink.wailsService.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 = "";
list.appendChild(
createElement("div", "sshkey-error", {
textContent: "Failed to load keys: " + String(err),
}),
);
}
}
_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.AlchementaryLink.wailsService.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.AlchementaryLink.wailsService.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_link" 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.AlchementaryLink.wailsService.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");
const header = createElement("div", "modal-header");
header.appendChild(
createElement("h3", "modal-title", { textContent: title }),
);
header.appendChild(
createElement("button", "btn btn-icon btn-xs modal-close", {
textContent: "x",
type: "button",
"aria-label": "Close",
}),
);
const body = createElement("div", "modal-body");
const messageEl = createElement("p", "", { textContent: message });
messageEl.style.fontSize = "var(--text-sm)";
messageEl.style.color = "var(--text-secondary)";
body.appendChild(messageEl);
const footer = createElement("div", "modal-footer");
footer.appendChild(
createElement("button", "btn btn-sm confirm-cancel", {
textContent: "Cancel",
type: "button",
}),
);
footer.appendChild(
createElement("button", "btn btn-sm btn-danger confirm-ok", {
textContent: confirmLabel,
type: "button",
}),
);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
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.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.SSHKeyManager = SSHKeyManager;
})();
-240
View File
@@ -1,240 +0,0 @@
(function () {
/**
* Alchementary Link — application state management.
* Provides a centralised store for profiles, sessions, and preferences,
* emitting change events so the UI stays in sync.
*/
class State {
constructor() {
/** @type {Array<Object>} */
this.profiles = [];
/** @type {Map<string, Object>} sessionId → SessionInfo */
this.sessions = new Map();
/** @type {string|null} */
this.activeSessionId = null;
/** @type {Object} */
this.preferences = {};
/** @type {boolean} */
this.secretStoreUnlocked = false;
/** @type {boolean} */
this.secretStoreInitialised = false;
/** @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();
}
// ── Event emitter ────────────────────────────────────────────────
/**
* Subscribes to a state change event.
* @param {string} event
* @param {Function} callback
*/
on(event, callback) {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
}
this._listeners.get(event).add(callback);
}
/**
* Unsubscribes from a state change event.
* @param {string} event
* @param {Function} callback
*/
off(event, callback) {
const set = this._listeners.get(event);
if (set) {
set.delete(callback);
}
}
/**
* Emits a state change event to all subscribers.
* @param {string} event
* @param {*} data
*/
emit(event, data) {
const set = this._listeners.get(event);
if (set) {
for (const cb of set) {
try {
cb(data);
} catch (err) {
console.error(`State listener error [${event}]:`, err);
}
}
}
}
// ── Profiles ─────────────────────────────────────────────────────
/**
* Replaces the entire profiles list.
* @param {Array<Object>} profiles
*/
setProfiles(profiles) {
this.profiles = profiles;
this.emit("profiles:changed", this.profiles);
}
/**
* Adds a single profile.
* @param {Object} profile
*/
addProfile(profile) {
this.profiles.push(profile);
this.emit("profiles:changed", this.profiles);
this.emit("profile:added", profile);
}
/**
* Updates an existing profile in place.
* @param {Object} profile
*/
updateProfile(profile) {
const idx = this.profiles.findIndex((p) => p.id === profile.id);
if (idx !== -1) {
this.profiles[idx] = profile;
}
this.emit("profiles:changed", this.profiles);
this.emit("profile:updated", profile);
}
/**
* Removes a profile by ID.
* @param {string} id
*/
removeProfile(id) {
this.profiles = this.profiles.filter((p) => p.id !== id);
this.emit("profiles:changed", this.profiles);
this.emit("profile:removed", id);
}
/**
* Returns a profile by ID, or undefined.
* @param {string} id
* @returns {Object|undefined}
*/
getProfile(id) {
return this.profiles.find((p) => p.id === id);
}
// ── Sessions ─────────────────────────────────────────────────────
/**
* Registers a new session.
* @param {Object} sessionInfo
*/
addSession(sessionInfo) {
this.sessions.set(sessionInfo.id, sessionInfo);
this.emit("sessions:changed", this.sessions);
this.emit("session:added", sessionInfo);
}
/**
* Merges partial updates into an existing session.
* @param {string} id
* @param {Object} updates
*/
updateSession(id, updates) {
const existing = this.sessions.get(id);
if (existing) {
Object.assign(existing, updates);
this.emit("sessions:changed", this.sessions);
this.emit("session:updated", existing);
}
}
/**
* Removes a session.
* @param {string} id
*/
removeSession(id) {
this.sessions.delete(id);
if (this.broadcastTargets) {
this.broadcastTargets.delete(id);
}
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.emit("sessions:changed", this.sessions);
this.emit("session:removed", id);
}
/**
* Returns session info by ID, or undefined.
* @param {string} id
* @returns {Object|undefined}
*/
getSession(id) {
return this.sessions.get(id);
}
/**
* Sets the currently visible/active session.
* @param {string|null} id
*/
setActiveSession(id) {
this.activeSessionId = id;
this.emit("session:activated", id);
}
/**
* Returns an ordered array of all sessions.
* @returns {Array<Object>}
*/
getAllSessions() {
return [...this.sessions.values()];
}
// ── Preferences ──────────────────────────────────────────────────
/**
* Replaces the entire preferences object.
* @param {Object} prefs
*/
setPreferences(prefs) {
this.preferences = prefs;
this.emit("preferences:changed", this.preferences);
}
// ── Secret store ─────────────────────────────────────────────────
/**
* Updates the secret store state.
* @param {boolean} unlocked
*/
setSecretStoreState(unlocked) {
this.secretStoreUnlocked = unlocked;
this.emit("secretStore:changed", unlocked);
}
/**
* Marks whether the secret store has been initialised (vault exists).
* @param {boolean} initialised
*/
setSecretStoreInitialised(initialised) {
this.secretStoreInitialised = initialised;
this.emit("secretStore:init", initialised);
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.State = State;
})();
-933
View File
@@ -1,933 +0,0 @@
(function () {
/**
* Alchementary Link — tab management.
* Handles the tab bar: adding, removing, reordering, renaming,
* pinning tabs, and context menus.
*/
const {
createElement,
escapeHtml,
protocolColour,
stateLabel,
dismissContextMenus,
} = window.AlchementaryLink;
class TabManager {
/**
* @param {App} app
*/
constructor(app) {
this.app = app;
/** @type {HTMLElement} The inner tab list where tab elements are inserted. */
this.tabList = document.getElementById("tab-list");
/** @type {HTMLElement} The outer tab bar wrapper (used for drag-and-drop). */
this.tabBar = document.getElementById("tab-bar");
/** @type {HTMLElement} The container for terminal/viewer panels. */
this.panelContainer = document.getElementById("panel-container");
/** @type {string[]} Ordered list of session IDs. */
this.order = [];
this.tooltip = null;
this.tooltipTimer = null;
this.tooltipSessionId = null;
this._setupDragAndDrop();
this._setupScrolling();
this._setupTooltip();
}
// ── Public API ───────────────────────────────────────────────────
/**
* Adds a new tab for a session and makes it active.
* @param {Object} sessionInfo
*/
addTab(sessionInfo) {
this.order.push(sessionInfo.id);
this._renderTab(sessionInfo);
// Create panel container for this session's terminal/viewer
const panel = createElement("div", "session-panel", {
id: `panel-${sessionInfo.id}`,
dataset: { sessionId: sessionInfo.id },
});
panel.style.display = "none";
this.panelContainer.appendChild(panel);
// Hide the empty-state BEFORE activating the panel so the
// panel-container gets the full workspace height immediately.
this._updateEmptyState();
this._updateScrollState();
this.setActive(sessionInfo.id);
}
/**
* Removes a tab and its panel.
* @param {string} sessionId
*/
removeTab(sessionId) {
this._hideTabTooltip();
const idx = this.order.indexOf(sessionId);
const wasActive = this.app.state.activeSessionId === sessionId;
if (idx !== -1) this.order.splice(idx, 1);
// Remove tab element
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl) tabEl.remove();
// Remove panel
const panel = document.getElementById(`panel-${sessionId}`);
if (panel) panel.remove();
// Activate the nearest neighbour
if (wasActive) {
const next =
this.order.length > 0
? this.order[Math.min(idx, this.order.length - 1)]
: null;
if (next) {
this.setActive(next);
} else {
this.app.state.setActiveSession(null);
}
}
this._updateEmptyState();
this._updateScrollState();
}
/**
* Switches the visible tab/panel to the given session.
* @param {string} sessionId
*/
setActive(sessionId) {
this.app.state.setActiveSession(sessionId);
// 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
this.panelContainer.querySelectorAll(".session-panel").forEach((p) => {
p.style.display = p.dataset.sessionId === sessionId ? "flex" : "none";
});
// Notify sidebar
const session = this.app.state.getSession(sessionId);
if (session) {
this.app.sidebar.highlightActive(session.profileId);
}
this.app.leviathan?.handleTabFocus?.(sessionId);
this._scrollTabIntoView(sessionId);
// 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);
});
});
}
/**
* Closes the currently active tab.
*/
closeActive() {
if (this.app.state.activeSessionId) {
this.closeTab(this.app.state.activeSessionId);
}
}
/**
* Reconnects a session by ID.
* @param {string} sessionId
*/
reconnectSession(sessionId) {
return this._reconnect(sessionId);
}
/**
* Disconnects and closes a tab by session ID.
* @param {string} sessionId
*/
async closeTab(sessionId) {
this._hideTabTooltip();
const session = this.app.state.getSession(sessionId);
// Ask for confirmation if connected and the preference is set
if (
session &&
session.state === "connected" &&
session.protocol !== "leviathan" &&
session.protocol !== "github" &&
this.app.state.preferences.confirmOnDisconnect
) {
const ok = await this._confirmClose(sessionId);
if (!ok) return;
}
await this.app.closeSessionImmediate(sessionId);
}
/**
* Shows a confirmation modal before closing a connected session.
* @param {string} sessionId
* @returns {Promise<boolean>}
* @private
*/
_confirmClose(sessionId) {
const createElement = window.AlchementaryLink.createElement;
const label = escapeHtml(this._tabLabel(sessionId));
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">Close Session</h3>
<button class="btn btn-icon btn-xs modal-close">&times;</button>
</div>
<div class="modal-body">
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
Disconnect and close <strong>${label}</strong>?
</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm confirm-cancel">Cancel</button>
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</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);
});
});
}
/**
* Renames a tab.
* @param {string} sessionId
* @param {string} name
*/
renameTab(sessionId, name) {
this.app.state.updateSession(sessionId, { tabName: name });
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl) {
tabEl.querySelector(".tab-name").textContent = name;
}
}
/**
* Pins a tab (moves it to the front).
* @param {string} sessionId
*/
pinTab(sessionId) {
this.app.state.updateSession(sessionId, { pinned: true });
this._reorderDOM();
this._updateTabStyles(sessionId);
}
/**
* Unpins a tab.
* @param {string} sessionId
*/
unpinTab(sessionId) {
this.app.state.updateSession(sessionId, { pinned: false });
this._reorderDOM();
this._updateTabStyles(sessionId);
}
/**
* Moves a tab one position to the left.
* @param {string} sessionId
*/
moveLeft(sessionId) {
const idx = this.order.indexOf(sessionId);
if (idx > 0) {
[this.order[idx - 1], this.order[idx]] = [
this.order[idx],
this.order[idx - 1],
];
this._reorderDOM();
}
}
/**
* Moves a tab one position to the right.
* @param {string} sessionId
*/
moveRight(sessionId) {
const idx = this.order.indexOf(sessionId);
if (idx >= 0 && idx < this.order.length - 1) {
[this.order[idx], this.order[idx + 1]] = [
this.order[idx + 1],
this.order[idx],
];
this._reorderDOM();
}
}
/**
* Opens a new session with the same profile as the given tab.
* @param {string} sessionId
*/
async duplicateTab(sessionId) {
const session = this.app.state.getSession(sessionId);
if (session && session.profileId) {
await this.app.connectProfile(session.profileId);
}
}
/**
* Updates the visual state of a tab when session state changes.
* @param {string} sessionId
* @param {string} state - connecting | connected | disconnected | error
* @param {string} [error]
*/
updateSessionState(sessionId, state, error) {
this.app.state.updateSession(sessionId, { state, error: error || "" });
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (!tabEl) return;
const dot = tabEl.querySelector(".tab-status");
if (dot) {
dot.className = `tab-status status-${state}`;
dot.title = stateLabel(state);
}
if (state === "error" && error) {
this.app.notifications.error(`${this._tabLabel(sessionId)}: ${error}`);
}
}
/**
* Handles a session closed event from the backend.
* @param {string} sessionId
*/
handleSessionClosed(sessionId) {
this.updateSessionState(sessionId, "disconnected");
}
/**
* Shows a right-click context menu for a tab.
* @param {string} sessionId
* @param {number} x
* @param {number} y
*/
showContextMenu(sessionId, x, y) {
dismissContextMenus();
const session = this.app.state.getSession(sessionId);
if (!session) return;
const menu = createElement("div", "context-menu");
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const isPinned = session.pinned;
const items = [
{ label: "Rename…", action: () => this._promptRename(sessionId) },
{ label: "Duplicate", action: () => this.duplicateTab(sessionId) },
{ divider: true },
{
label: isPinned ? "Unpin" : "Pin",
action: () =>
isPinned ? this.unpinTab(sessionId) : this.pinTab(sessionId),
},
{ label: "Move Left", action: () => this.moveLeft(sessionId) },
{ label: "Move Right", action: () => this.moveRight(sessionId) },
{ divider: true },
{
label: "Reconnect",
action: () => this._reconnect(sessionId),
disabled:
session.state === "connected" || session.state === "connecting",
},
{ divider: true },
{
label: "Close",
className: "context-menu-danger",
action: () => this.closeTab(sessionId),
},
];
for (const entry of items) {
if (entry.divider) {
menu.appendChild(createElement("div", "context-menu-divider"));
continue;
}
const btn = createElement(
"button",
`context-menu-item ${entry.className || ""}`,
{
textContent: entry.label,
},
);
if (entry.disabled) {
btn.disabled = true;
btn.classList.add("context-menu-disabled");
}
btn.addEventListener("click", () => {
dismissContextMenus();
entry.action();
});
menu.appendChild(btn);
}
document.body.appendChild(menu);
// Keep within viewport
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth)
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
if (rect.bottom > window.innerHeight)
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
}
/**
* Returns the current tab ordering.
* @returns {string[]}
*/
getTabOrder() {
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}
*/
getNextTab() {
if (this.order.length === 0) return null;
const idx = this.order.indexOf(this.app.state.activeSessionId);
return this.order[(idx + 1) % this.order.length];
}
/**
* Returns the session ID of the previous tab (wrapping round).
* @returns {string|null}
*/
getPrevTab() {
if (this.order.length === 0) return null;
const idx = this.order.indexOf(this.app.state.activeSessionId);
return this.order[(idx - 1 + this.order.length) % this.order.length];
}
// ── Rendering ────────────────────────────────────────────────────
/**
* Creates and appends a tab element.
* @param {Object} sessionInfo
* @private
*/
_renderTab(sessionInfo) {
const tab = createElement("div", "tab", {
dataset: { sessionId: sessionInfo.id },
draggable: "true",
});
// Close button
const closeBtn = createElement("button", "tab-close", {
textContent: "×",
"aria-label": "Close tab",
});
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this._hideTabTooltip();
this.closeTab(sessionInfo.id);
});
tab.appendChild(closeBtn);
// Protocol badge
const protoLabel =
sessionInfo.protocol === "github"
? "GitHub"
: sessionInfo.protocol === "local"
? "Local"
: sessionInfo.protocol.toUpperCase();
const badge = createElement(
"span",
`tab-badge badge-${sessionInfo.protocol}`,
{
textContent: protoLabel,
},
);
tab.appendChild(badge);
// Name
const name = createElement("span", "tab-name", {
textContent: sessionInfo.tabName || sessionInfo.protocol.toUpperCase(),
});
tab.appendChild(name);
// Status dot
const status = createElement(
"span",
`tab-status status-${sessionInfo.state || "connecting"}`,
{
title: stateLabel(sessionInfo.state),
},
);
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");
}
// SFTP toggle button (SSH sessions only)
if (sessionInfo.protocol === "ssh") {
const sftpBtn = createElement("button", "tab-sftp", {
textContent: "📁",
title: "Toggle SFTP file browser",
"aria-label": "Toggle SFTP",
});
sftpBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.app.sftp.toggle(sessionInfo.id);
});
tab.appendChild(sftpBtn);
}
// Click → activate
tab.addEventListener("click", () => this.setActive(sessionInfo.id));
// Right-click → context menu
tab.addEventListener("contextmenu", (e) => {
e.preventDefault();
this.showContextMenu(sessionInfo.id, e.clientX, e.clientY);
});
this.tabList.appendChild(tab);
}
// ── Internal helpers ─────────────────────────────────────────────
/**
* Reorders the tab-bar DOM elements to match `this.order`,
* with pinned tabs always first.
* @private
*/
_reorderDOM() {
// Sort: pinned first, then by order index
const sorted = [...this.order].sort((a, b) => {
const sa = this.app.state.getSession(a);
const sb = this.app.state.getSession(b);
const pinnedA = sa?.pinned ? 0 : 1;
const pinnedB = sb?.pinned ? 0 : 1;
if (pinnedA !== pinnedB) return pinnedA - pinnedB;
return this.order.indexOf(a) - this.order.indexOf(b);
});
for (const id of sorted) {
const el = this.tabList.querySelector(`[data-session-id="${id}"]`);
if (el) this.tabList.appendChild(el);
}
this._updateScrollState();
}
/**
* Updates the visual pin state on a tab element.
* @param {string} sessionId
* @private
*/
_updateTabStyles(sessionId) {
const session = this.app.state.getSession(sessionId);
const tabEl = this.tabList.querySelector(
`[data-session-id="${sessionId}"]`,
);
if (tabEl && session) {
tabEl.classList.toggle("tab-pinned", !!session.pinned);
}
}
/**
* Makes the tab strip usable when the tab list overflows horizontally.
* @private
*/
_setupScrolling() {
if (!this.tabList) return;
this.tabList.addEventListener(
"wheel",
(event) => {
if (this.tabList.scrollWidth <= this.tabList.clientWidth) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (!delta) return;
event.preventDefault();
this.tabList.scrollLeft += delta;
this._updateScrollState();
},
{ passive: false },
);
this.tabList.addEventListener("scroll", () => this._updateScrollState(), {
passive: true,
});
window.addEventListener("resize", () => this._updateScrollState());
}
/**
* Keeps the active tab visible inside the scrollable tab list.
* @param {string} sessionId
* @private
*/
_scrollTabIntoView(sessionId) {
const tab = this.tabList?.querySelector(`[data-session-id="${sessionId}"]`);
if (!tab) return;
requestAnimationFrame(() => {
tab.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
this._updateScrollState();
});
}
/**
* Reflects whether tab overflow exists at either edge.
* @private
*/
_updateScrollState() {
if (!this.tabList || !this.tabBar) return;
const max = this.tabList.scrollWidth - this.tabList.clientWidth;
this.tabBar.classList.toggle("has-tab-overflow", max > 1);
this.tabBar.classList.toggle("has-overflow-left", this.tabList.scrollLeft > 1);
this.tabBar.classList.toggle(
"has-overflow-right",
max > 1 && this.tabList.scrollLeft < max - 1,
);
}
/**
* Shows a custom hover hint for tabs with full names and connection start.
* @private
*/
_setupTooltip() {
if (!this.tabList) return;
this.tooltip = createElement("div", "tooltip tab-tooltip", {
role: "tooltip",
});
document.body.appendChild(this.tooltip);
this.tabList.addEventListener("pointerover", (event) => {
const tab = event.target.closest(".tab");
if (!tab || !this.tabList.contains(tab)) return;
if (tab.contains(event.relatedTarget)) return;
this._clearTabTooltipTimer();
this.tooltipTimer = setTimeout(() => {
this.tooltipTimer = null;
this._showTabTooltip(tab.dataset.sessionId, tab);
}, 250);
});
this.tabList.addEventListener("pointerdown", () => this._hideTabTooltip());
this.tabList.addEventListener("pointermove", (event) => {
const tab = event.target.closest(".tab");
if (!tab || !this.tooltip?.classList.contains("is-visible")) return;
this._positionTabTooltip(tab);
});
this.tabList.addEventListener("pointerout", (event) => {
const tab = event.target.closest(".tab");
if (!tab || tab.contains(event.relatedTarget)) return;
this._hideTabTooltip();
});
this.tabList.addEventListener("scroll", () => this._hideTabTooltip(), {
passive: true,
});
window.addEventListener("blur", () => this._hideTabTooltip());
document.addEventListener("visibilitychange", () => {
if (document.hidden) this._hideTabTooltip();
});
}
/**
* Renders and positions the tab hover tooltip.
* @param {string} sessionId
* @param {HTMLElement} tab
* @private
*/
_showTabTooltip(sessionId, tab) {
const session = this.app.state.getSession(sessionId);
if (!session || !this.tooltip || !tab?.isConnected || !this.tabList.contains(tab)) {
this._hideTabTooltip();
return;
}
this.tooltip.replaceChildren();
this.tooltip.appendChild(
createElement("div", "tab-tooltip-title", {
textContent: this._tabLabel(sessionId),
}),
);
if (
session.protocol !== "leviathan" &&
session.state === "connected" &&
session.connectedAt
) {
const connected = this._formatConnectedSince(session.connectedAt);
if (connected) {
this.tooltip.appendChild(
createElement("div", "tab-tooltip-meta", {
textContent: `Connected since: ${connected}`,
}),
);
}
}
this._positionTabTooltip(tab);
this.tooltipSessionId = sessionId;
this.tooltip.classList.add("is-visible");
}
/**
* Positions the tab tooltip under the hovered tab.
* @param {HTMLElement} tab
* @private
*/
_positionTabTooltip(tab) {
if (!this.tooltip) return;
if (!tab?.isConnected || !this.tabList.contains(tab)) {
this._hideTabTooltip();
return;
}
const rect = tab.getBoundingClientRect();
const tooltipRect = this.tooltip.getBoundingClientRect();
const left = Math.min(
Math.max(8, rect.left + rect.width / 2 - tooltipRect.width / 2),
window.innerWidth - tooltipRect.width - 8,
);
const top = Math.min(rect.bottom + 8, window.innerHeight - tooltipRect.height - 8);
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
}
/**
* Hides the tab hover tooltip.
* @private
*/
_hideTabTooltip() {
this._clearTabTooltipTimer();
this.tooltipSessionId = null;
if (!this.tooltip) return;
this.tooltip.classList.remove("is-visible");
this.tooltip.replaceChildren();
}
/**
* Cancels any pending tab tooltip show timer.
* @private
*/
_clearTabTooltipTimer() {
if (!this.tooltipTimer) return;
clearTimeout(this.tooltipTimer);
this.tooltipTimer = null;
}
/**
* Formats a session connection timestamp for the tab tooltip.
* @param {string} value
* @returns {string}
* @private
*/
_formatConnectedSince(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return "";
try {
return date.toLocaleString([], {
dateStyle: "medium",
timeStyle: "short",
});
} catch (_) {
return date.toLocaleString();
}
}
/**
* Shows/hides the empty-state panel based on whether tabs exist.
* @private
*/
_updateEmptyState() {
const empty = document.getElementById("empty-state");
if (empty) {
empty.style.display = this.order.length === 0 ? "flex" : "none";
}
}
/**
* Prompts the user to rename a tab.
* @param {string} sessionId
* @private
*/
_promptRename(sessionId) {
const session = this.app.state.getSession(sessionId);
const current = session?.tabName || "";
// Use the app's custom prompt modal (window.prompt is unavailable in WKWebView)
this.app._inputPrompt("Rename Tab", "Tab name", current).then((name) => {
if (name !== null && name.trim()) {
this.renameTab(sessionId, name.trim());
}
});
}
/**
* Attempts to reconnect a disconnected session.
* @param {string} sessionId
* @private
*/
async _reconnect(sessionId) {
const session = this.app.state.getSession(sessionId);
if (!session || !session.profileId) return;
// Fully close the old session before opening a fresh connection.
await this.app.closeSessionImmediate(sessionId);
await this.app.connectProfile(session.profileId);
}
/**
* Returns the display label for a session's tab.
* @param {string} sessionId
* @returns {string}
* @private
*/
_tabLabel(sessionId) {
const session = this.app.state.getSession(sessionId);
return session?.tabName || "Session";
}
// ── Drag and drop ────────────────────────────────────────────────
/**
* Sets up drag-and-drop reordering on the tab bar.
* @private
*/
_setupDragAndDrop() {
if (!this.tabList) return;
let draggedId = null;
this.tabList.addEventListener("dragstart", (e) => {
const tab = e.target.closest(".tab");
if (!tab) return;
draggedId = tab.dataset.sessionId;
tab.classList.add("tab-dragging");
e.dataTransfer.effectAllowed = "move";
});
this.tabList.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const target = e.target.closest(".tab");
if (!target || target.dataset.sessionId === draggedId) return;
const rect = target.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
target.classList.toggle("tab-drop-before", e.clientX < midX);
target.classList.toggle("tab-drop-after", e.clientX >= midX);
});
this.tabList.addEventListener("dragleave", (e) => {
const target = e.target.closest(".tab");
if (target) {
target.classList.remove("tab-drop-before", "tab-drop-after");
}
});
this.tabList.addEventListener("drop", (e) => {
e.preventDefault();
const target = e.target.closest(".tab");
if (!target || !draggedId) return;
target.classList.remove("tab-drop-before", "tab-drop-after");
const targetId = target.dataset.sessionId;
if (targetId === draggedId) return;
// Reorder
const fromIdx = this.order.indexOf(draggedId);
const toIdx = this.order.indexOf(targetId);
if (fromIdx === -1 || toIdx === -1) return;
this.order.splice(fromIdx, 1);
this.order.splice(toIdx, 0, draggedId);
this._reorderDOM();
});
this.tabList.addEventListener("dragend", () => {
this.tabList.querySelectorAll(".tab").forEach((t) => {
t.classList.remove(
"tab-dragging",
"tab-drop-before",
"tab-drop-after",
);
});
draggedId = null;
});
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.TabManager = TabManager;
})();
File diff suppressed because it is too large Load Diff
-347
View File
@@ -1,347 +0,0 @@
(function () {
/**
* Alchementary Link — 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 = {
charcoal: {
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",
},
},
"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",
},
},
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",
},
},
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",
},
},
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",
},
},
"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",
},
},
"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",
},
},
"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",
},
},
"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"];
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() {
return Object.entries(THEMES).map(([value, entry]) => ({
value,
label: entry.label,
}));
}
window.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.getTerminalTheme = getTerminalTheme;
window.AlchementaryLink.listTerminalThemes = listTerminalThemes;
})();
-381
View File
@@ -1,381 +0,0 @@
(function () {
/**
* Alchementary Link — Trigger Rules Engine.
* Monitors terminal output and fires actions (highlight, notify, sound)
* when patterns match. Rules are stored in localStorage.
*/
const { createElement } = window.AlchementaryLink;
const STORAGE_KEY = "link: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">&times;</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.AlchementaryLink = window.AlchementaryLink || {};
window.AlchementaryLink.TriggerEngine = TriggerEngine;
})();
-402
View File
@@ -1,402 +0,0 @@
(function () {
/**
* Alchementary Link — utility functions.
* General-purpose helpers used throughout the application.
*/
/**
* Shorthand for document.querySelector.
* @param {string} selector - CSS selector.
* @returns {Element|null}
*/
function $(selector) {
return document.querySelector(selector);
}
/**
* Shorthand for document.querySelectorAll.
* @param {string} selector - CSS selector.
* @returns {NodeListOf<Element>}
*/
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 = {}) {
const el = document.createElement(tag);
if (className) {
el.className = className;
}
for (const [key, value] of Object.entries(attrs)) {
if (key === "textContent") {
el.textContent = value;
} else if (key === "innerHTML") {
el.innerHTML = value;
} else if (key === "dataset") {
for (const [dKey, dVal] of Object.entries(value)) {
el.dataset[dKey] = dVal;
}
} else if (key.startsWith("on") && typeof value === "function") {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
return el;
}
/**
* Creates a standard modal skeleton without HTML string interpolation.
* @param {string} title
* @param {string} [sizeClass]
* @returns {{backdrop: HTMLElement, modal: HTMLElement, body: HTMLElement, footer: HTMLElement, closeButton: HTMLElement, show: Function, remove: Function}}
*/
function createModalShell(title, sizeClass = "modal-sm") {
const backdrop = createElement("div", "modal-backdrop");
const modal = createElement("div", `modal ${sizeClass}`);
const header = createElement("div", "modal-header");
const titleEl = createElement("h3", "modal-title", { textContent: title });
const closeButton = createElement("button", "btn btn-icon btn-xs modal-close", {
textContent: "x",
"aria-label": "Close",
});
const body = createElement("div", "modal-body");
const footer = createElement("div", "modal-footer");
header.appendChild(titleEl);
header.appendChild(closeButton);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
return {
backdrop,
modal,
body,
footer,
closeButton,
show() {
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
backdrop.classList.add("is-visible");
modal.classList.add("is-visible");
});
},
remove() {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
setTimeout(() => backdrop.remove(), 200);
},
};
}
/**
* 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 "";
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
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 "";
const d = date instanceof Date ? date : new Date(date);
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");
if (isToday) {
return `${hours}:${mins}:${secs}`;
}
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) {
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) {
if (!query) return { match: true, score: 1 };
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let textIdx = 0;
let score = 0;
let consecutiveBonus = 0;
for (let qi = 0; qi < lowerQuery.length; qi++) {
const ch = lowerQuery[qi];
const foundAt = lowerText.indexOf(ch, textIdx);
if (foundAt === -1) {
return { match: false, score: 0 };
}
// Reward consecutive character matches and early matches
if (foundAt === textIdx) {
consecutiveBonus += 1;
} else {
consecutiveBonus = 0;
}
score += 1 + consecutiveBonus + (foundAt === 0 ? 2 : 0);
textIdx = foundAt + 1;
}
return { match: true, score };
}
/**
* Base64-encodes a string.
* @param {string} str
* @returns {string}
*/
function base64Encode(str) {
return btoa(str);
}
/**
* UTF-8-encodes a string and then base64-encodes the bytes.
* Unlike bare btoa(), this handles characters above U+007F correctly.
* @param {string} str
* @returns {string}
*/
function utf8ToBase64(str) {
const bytes = new TextEncoder().encode(str);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Base64-decodes a string.
* @param {string} str
* @returns {string}
*/
function base64Decode(str) {
return atob(str);
}
/**
* Generates a new UUID v4 using the browser crypto API.
* @returns {string}
*/
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"];
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) + "…";
}
/**
* Removes any open context menus from the DOM.
*/
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) {
switch (protocol) {
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 "irc":
return "proto-irc";
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 "";
const d = new Date(isoDate);
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";
const mins = Math.floor(secs / 60);
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`;
const days = Math.floor(hrs / 24);
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`;
const years = Math.floor(months / 12);
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) {
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";
}
}
window.AlchementaryLink = window.AlchementaryLink || {};
Object.assign(window.AlchementaryLink, {
$,
$$,
createElement,
createModalShell,
escapeHtml,
formatTimestamp,
debounce,
fuzzyMatch,
base64Encode,
utf8ToBase64,
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");
}
// Apply to existing inputs
document.querySelectorAll("input, textarea").forEach(disableAutoCapitalise);
// 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") {
disableAutoCapitalise(node);
}
if (node.querySelectorAll) {
node
.querySelectorAll("input, textarea")
.forEach(disableAutoCapitalise);
}
}
}
});
inputObserver.observe(document.body, { childList: true, subtree: true });
})();
-331
View File
@@ -1,331 +0,0 @@
(function () {
/**
* Alchementary Link — VNC viewer.
* Renders a noVNC canvas inside a session panel, connecting to the local
* WebSocket proxy started by the Go backend.
*/
const { createElement, $ } = window.AlchementaryLink;
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._rfbListeners = [];
this._credentialDialog = null;
this._destroyed = false;
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 (this._destroyed) return;
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._addRFBListener("connect", () => {
this.statusEl.textContent = "Connected";
this.statusEl.classList.remove("vnc-status-error");
this.statusEl.classList.add("vnc-status-connected");
});
this._addRFBListener("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._addRFBListener("credentialsrequired", () => {
this.statusEl.textContent = "Credentials required";
// TODO: prompt for VNC password via modal
this._promptCredentials();
});
this._addRFBListener("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();
}
}
_addRFBListener(name, handler) {
this.rfb.addEventListener(name, handler);
this._rfbListeners.push({ name, handler });
}
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() {
if (this._destroyed) return;
this._closeCredentialDialog();
// 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 dialog = {
backdrop,
modal,
removeTimer: null,
focusTimer: null,
};
this._credentialDialog = dialog;
const close = () => {
backdrop.classList.remove("is-visible");
modal.classList.remove("is-visible");
dialog.removeTimer = setTimeout(() => {
if (this._credentialDialog === dialog) {
this._closeCredentialDialog();
}
}, 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();
});
dialog.focusTimer = setTimeout(() => input.focus(), 50);
}
_closeCredentialDialog() {
const dialog = this._credentialDialog;
if (!dialog) return;
if (dialog.removeTimer) clearTimeout(dialog.removeTimer);
if (dialog.focusTimer) clearTimeout(dialog.focusTimer);
dialog.backdrop.remove();
dialog.modal.remove();
this._credentialDialog = null;
}
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() {
this._destroyed = true;
this._closeCredentialDialog();
if (this.rfb) {
for (const { name, handler } of this._rfbListeners) {
this.rfb.removeEventListener(name, handler);
}
this._rfbListeners = [];
this.rfb.disconnect();
this.rfb = null;
}
this.container.innerHTML = "";
this.container.classList.remove("vnc-viewer-panel");
}
}
// Export to global namespace.
window.AlchementaryLink.VNCViewer = new VNCViewer();
})();
-28
View File
@@ -1,28 +0,0 @@
(function () {
const root = (window.AlchementaryLink = window.AlchementaryLink || {});
const directService = () => window.go?.api?.Service || null;
const unavailable = (name) =>
Promise.reject(new Error(`Wails service method is not available: ${String(name)}`));
root.wailsService = new Proxy(
{},
{
get(_target, prop) {
if (prop === "then") return undefined;
const service = directService();
const value = service?.[prop];
if (typeof value === "function") return value.bind(service);
if (value !== undefined) return value;
return () => unavailable(prop);
},
has(_target, prop) {
return prop in (directService() || {});
},
},
);
root.wailsServiceReady = Promise.resolve(root.wailsService);
root.hasWailsService = () =>
Boolean(root.wailsService && window.runtime && directService());
})();
-261
View File
@@ -1,261 +0,0 @@
// 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>>;
export function CleanupRecordings(arg1:number,arg2:number):Promise<Record<string, any>>;
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>;
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
export function CreateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
export function DeleteKnownHost(arg1:string):Promise<void>;
export function DeleteProfile(arg1:string):Promise<void>;
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>;
export function DisconnectSession(arg1:string):Promise<void>;
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
export function DownloadGitHubRepoArchiveToPath(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
export function ExportProfiles(arg1:boolean):Promise<string>;
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
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 GetRecordingStorageInfo():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 IRCHandleInput(arg1:string,arg2:string,arg3:string):Promise<void>;
export function IRCJoin(arg1:string,arg2:string):Promise<void>;
export function IRCListRooms(arg1:string):Promise<void>;
export function IRCPart(arg1:string,arg2:string):Promise<void>;
export function IRCRooms(arg1:string):Promise<Array<Record<string, any>>>;
export function IRCUsers(arg1:string,arg2:string):Promise<Array<string>>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise<number>;
export function ImportSSHConfig(arg1:string,arg2:string):Promise<number>;
export function InitialiseSecretStore(arg1:string):Promise<void>;
export function IsRecording(arg1:string):Promise<boolean>;
export function IsSecretStoreInitialised():Promise<boolean>;
export function IsSecretStoreUnlocked():Promise<boolean>;
export function LeviathanExportJSON(arg1:string,arg2:Record<string, any>):Promise<string>;
export function LeviathanExportRange(arg1:string,arg2:number,arg3:number):Promise<string>;
export function LeviathanLoadFile(arg1:string,arg2:boolean):Promise<Record<string, any>>;
export function LeviathanOpenCompare():Promise<Record<string, any>>;
export function LeviathanOpenFile():Promise<Record<string, any>>;
export function LeviathanRedo():Promise<api.leviathanDocState>;
export function LeviathanRevert():Promise<api.leviathanDocState>;
export function LeviathanRunTool(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>>;
export function LeviathanSaveWorking():Promise<string>;
export function LeviathanSaveWorkingToPath(arg1:string):Promise<string>;
export function LeviathanSetByte(arg1:number,arg2:string):Promise<Record<string, any>>;
export function LeviathanState():Promise<api.leviathanDocState>;
export function LeviathanUndo():Promise<api.leviathanDocState>;
export function ListAuditEntries(arg1:number):Promise<Array<models.AuditEntry>>;
export function ListFavourites():Promise<Array<models.Profile>>;
export function ListKnownHosts():Promise<Array<models.KnownHost>>;
export function ListPortForwards(arg1:string):Promise<Array<models.PortForward>>;
export function ListProfiles():Promise<Array<models.Profile>>;
export function ListProfilesByFolder(arg1:string):Promise<Array<models.Profile>>;
export function ListProfilesByProtocol(arg1:string):Promise<Array<models.Profile>>;
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>>;
export function ListSnippets():Promise<Array<models.Snippet>>;
export function LockSecretStore():Promise<void>;
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>>;
export function RegisterSecretResponseListener():Promise<void>;
export function RenameSession(arg1:string,arg2:string):Promise<void>;
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
export function RespondSecret(arg1:string,arg2:string,arg3:boolean):Promise<void>;
export function RestartLocalSession(arg1:string,arg2:number,arg3:number):Promise<void>;
export function SFTPDownload(arg1:string,arg2:string):Promise<void>;
export function SFTPDownloadToPath(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPList(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function SFTPMkdir(arg1:string,arg2:string):Promise<void>;
export function SFTPPwd(arg1:string):Promise<string>;
export function SFTPRemove(arg1:string,arg2:string):Promise<void>;
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
export function SFTPUploadCancel(arg1:string):Promise<void>;
export function SFTPUploadChunk(arg1:string,arg2:string):Promise<void>;
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function SFTPUploadFinish(arg1:string):Promise<void>;
export function SFTPUploadPath(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SFTPUploadStart(arg1:string,arg2:string,arg3:string,arg4:number):Promise<string>;
export function SaveFile(arg1:string,arg2:string):Promise<string>;
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
export function SaveSessionCapture(arg1:string,arg2:string):Promise<void>;
export function SearchProfiles(arg1:string):Promise<Array<models.Profile>>;
export function SelectFile(arg1:string,arg2:string):Promise<string>;
export function SendBreak(arg1:string):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetEventSink(arg1:any):Promise<void>;
export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SetTOTPSecret(arg1:string,arg2:string):Promise<void>;
export function Shutdown():Promise<void>;
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
export function StartRecording(arg1:string,arg2:number,arg3:number):Promise<string>;
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
export function StopRecording(arg1:string):Promise<void>;
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
export function UnlockSecretStore(arg1:string):Promise<void>;
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
export function UpdateSnippet(arg1:models.Snippet):Promise<models.Snippet>;
export function WriteSession(arg1:string,arg2:string):Promise<void>;
export function WriteSessionEscaped(arg1:string,arg2:string):Promise<void>;
export function WriteSessionHex(arg1:string,arg2:string):Promise<void>;
export function WriteSessionText(arg1:string,arg2:string):Promise<void>;
-515
View File
@@ -1,515 +0,0 @@
// @ts-check
// 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);
}
export function CheckForUpdates() {
return window['go']['api']['Service']['CheckForUpdates']();
}
export function CleanupRecordings(arg1, arg2) {
return window['go']['api']['Service']['CleanupRecordings'](arg1, arg2);
}
export function ClearSessionHistory(arg1) {
return window['go']['api']['Service']['ClearSessionHistory'](arg1);
}
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);
}
export function ConnectSession(arg1) {
return window['go']['api']['Service']['ConnectSession'](arg1);
}
export function CreateProfile(arg1) {
return window['go']['api']['Service']['CreateProfile'](arg1);
}
export function CreateSnippet(arg1) {
return window['go']['api']['Service']['CreateSnippet'](arg1);
}
export function DeleteKnownHost(arg1) {
return window['go']['api']['Service']['DeleteKnownHost'](arg1);
}
export function DeleteProfile(arg1) {
return window['go']['api']['Service']['DeleteProfile'](arg1);
}
export function DeleteProfileSecrets(arg1) {
return window['go']['api']['Service']['DeleteProfileSecrets'](arg1);
}
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);
}
export function DeleteTOTPSecret(arg1) {
return window['go']['api']['Service']['DeleteTOTPSecret'](arg1);
}
export function DisconnectSession(arg1) {
return window['go']['api']['Service']['DisconnectSession'](arg1);
}
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
}
export function DownloadGitHubRepoArchiveToPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['DownloadGitHubRepoArchiveToPath'](arg1, arg2, arg3, arg4);
}
export function DuplicateProfile(arg1) {
return window['go']['api']['Service']['DuplicateProfile'](arg1);
}
export function ExportProfiles(arg1) {
return window['go']['api']['Service']['ExportProfiles'](arg1);
}
export function ExportProfilesEncrypted(arg1, arg2) {
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
}
export function FileExists(arg1) {
return window['go']['api']['Service']['FileExists'](arg1);
}
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']();
}
export function GetProfile(arg1) {
return window['go']['api']['Service']['GetProfile'](arg1);
}
export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1);
}
export function GetRecordingStorageInfo() {
return window['go']['api']['Service']['GetRecordingStorageInfo']();
}
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1);
}
export function GetSessionHistory(arg1) {
return window['go']['api']['Service']['GetSessionHistory'](arg1);
}
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);
}
export function IRCHandleInput(arg1, arg2, arg3) {
return window['go']['api']['Service']['IRCHandleInput'](arg1, arg2, arg3);
}
export function IRCJoin(arg1, arg2) {
return window['go']['api']['Service']['IRCJoin'](arg1, arg2);
}
export function IRCListRooms(arg1) {
return window['go']['api']['Service']['IRCListRooms'](arg1);
}
export function IRCPart(arg1, arg2) {
return window['go']['api']['Service']['IRCPart'](arg1, arg2);
}
export function IRCRooms(arg1) {
return window['go']['api']['Service']['IRCRooms'](arg1);
}
export function IRCUsers(arg1, arg2) {
return window['go']['api']['Service']['IRCUsers'](arg1, arg2);
}
export function ImportProfiles(arg1, arg2) {
return window['go']['api']['Service']['ImportProfiles'](arg1, arg2);
}
export function ImportProfilesEncrypted(arg1, arg2) {
return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2);
}
export function ImportSSHConfig(arg1, arg2) {
return window['go']['api']['Service']['ImportSSHConfig'](arg1, arg2);
}
export function InitialiseSecretStore(arg1) {
return window['go']['api']['Service']['InitialiseSecretStore'](arg1);
}
export function IsRecording(arg1) {
return window['go']['api']['Service']['IsRecording'](arg1);
}
export function IsSecretStoreInitialised() {
return window['go']['api']['Service']['IsSecretStoreInitialised']();
}
export function IsSecretStoreUnlocked() {
return window['go']['api']['Service']['IsSecretStoreUnlocked']();
}
export function LeviathanExportJSON(arg1, arg2) {
return window['go']['api']['Service']['LeviathanExportJSON'](arg1, arg2);
}
export function LeviathanExportRange(arg1, arg2, arg3) {
return window['go']['api']['Service']['LeviathanExportRange'](arg1, arg2, arg3);
}
export function LeviathanLoadFile(arg1, arg2) {
return window['go']['api']['Service']['LeviathanLoadFile'](arg1, arg2);
}
export function LeviathanOpenCompare() {
return window['go']['api']['Service']['LeviathanOpenCompare']();
}
export function LeviathanOpenFile() {
return window['go']['api']['Service']['LeviathanOpenFile']();
}
export function LeviathanRedo() {
return window['go']['api']['Service']['LeviathanRedo']();
}
export function LeviathanRevert() {
return window['go']['api']['Service']['LeviathanRevert']();
}
export function LeviathanRunTool(arg1, arg2) {
return window['go']['api']['Service']['LeviathanRunTool'](arg1, arg2);
}
export function LeviathanSaveWorking() {
return window['go']['api']['Service']['LeviathanSaveWorking']();
}
export function LeviathanSaveWorkingToPath(arg1) {
return window['go']['api']['Service']['LeviathanSaveWorkingToPath'](arg1);
}
export function LeviathanSetByte(arg1, arg2) {
return window['go']['api']['Service']['LeviathanSetByte'](arg1, arg2);
}
export function LeviathanState() {
return window['go']['api']['Service']['LeviathanState']();
}
export function LeviathanUndo() {
return window['go']['api']['Service']['LeviathanUndo']();
}
export function ListAuditEntries(arg1) {
return window['go']['api']['Service']['ListAuditEntries'](arg1);
}
export function ListFavourites() {
return window['go']['api']['Service']['ListFavourites']();
}
export function ListKnownHosts() {
return window['go']['api']['Service']['ListKnownHosts']();
}
export function ListPortForwards(arg1) {
return window['go']['api']['Service']['ListPortForwards'](arg1);
}
export function ListProfiles() {
return window['go']['api']['Service']['ListProfiles']();
}
export function ListProfilesByFolder(arg1) {
return window['go']['api']['Service']['ListProfilesByFolder'](arg1);
}
export function ListProfilesByProtocol(arg1) {
return window['go']['api']['Service']['ListProfilesByProtocol'](arg1);
}
export function ListProfilesByTag(arg1) {
return window['go']['api']['Service']['ListProfilesByTag'](arg1);
}
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']();
}
export function ListSessions() {
return window['go']['api']['Service']['ListSessions']();
}
export function ListSnippets() {
return window['go']['api']['Service']['ListSnippets']();
}
export function LockSecretStore() {
return window['go']['api']['Service']['LockSecretStore']();
}
export function NewProfile(arg1) {
return window['go']['api']['Service']['NewProfile'](arg1);
}
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);
}
export function RefreshGitHubRepos(arg1) {
return window['go']['api']['Service']['RefreshGitHubRepos'](arg1);
}
export function RegisterSecretResponseListener() {
return window['go']['api']['Service']['RegisterSecretResponseListener']();
}
export function RenameSession(arg1, arg2) {
return window['go']['api']['Service']['RenameSession'](arg1, arg2);
}
export function ResizeSession(arg1, arg2, arg3) {
return window['go']['api']['Service']['ResizeSession'](arg1, arg2, arg3);
}
export function RespondSecret(arg1, arg2, arg3) {
return window['go']['api']['Service']['RespondSecret'](arg1, arg2, arg3);
}
export function RestartLocalSession(arg1, arg2, arg3) {
return window['go']['api']['Service']['RestartLocalSession'](arg1, arg2, arg3);
}
export function SFTPDownload(arg1, arg2) {
return window['go']['api']['Service']['SFTPDownload'](arg1, arg2);
}
export function SFTPDownloadToPath(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPDownloadToPath'](arg1, arg2, arg3);
}
export function SFTPList(arg1, arg2) {
return window['go']['api']['Service']['SFTPList'](arg1, arg2);
}
export function SFTPMkdir(arg1, arg2) {
return window['go']['api']['Service']['SFTPMkdir'](arg1, arg2);
}
export function SFTPPwd(arg1) {
return window['go']['api']['Service']['SFTPPwd'](arg1);
}
export function SFTPRemove(arg1, arg2) {
return window['go']['api']['Service']['SFTPRemove'](arg1, arg2);
}
export function SFTPRename(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3);
}
export function SFTPStat(arg1, arg2) {
return window['go']['api']['Service']['SFTPStat'](arg1, arg2);
}
export function SFTPUpload(arg1, arg2) {
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
}
export function SFTPUploadCancel(arg1) {
return window['go']['api']['Service']['SFTPUploadCancel'](arg1);
}
export function SFTPUploadChunk(arg1, arg2) {
return window['go']['api']['Service']['SFTPUploadChunk'](arg1, arg2);
}
export function SFTPUploadData(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
}
export function SFTPUploadFinish(arg1) {
return window['go']['api']['Service']['SFTPUploadFinish'](arg1);
}
export function SFTPUploadPath(arg1, arg2, arg3) {
return window['go']['api']['Service']['SFTPUploadPath'](arg1, arg2, arg3);
}
export function SFTPUploadStart(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['SFTPUploadStart'](arg1, arg2, arg3, arg4);
}
export function SaveFile(arg1, arg2) {
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
}
export function SavePreferences(arg1) {
return window['go']['api']['Service']['SavePreferences'](arg1);
}
export function SaveSessionCapture(arg1, arg2) {
return window['go']['api']['Service']['SaveSessionCapture'](arg1, arg2);
}
export function SearchProfiles(arg1) {
return window['go']['api']['Service']['SearchProfiles'](arg1);
}
export function SelectFile(arg1, arg2) {
return window['go']['api']['Service']['SelectFile'](arg1, arg2);
}
export function SendBreak(arg1) {
return window['go']['api']['Service']['SendBreak'](arg1);
}
export function SetContext(arg1) {
return window['go']['api']['Service']['SetContext'](arg1);
}
export function SetEventSink(arg1) {
return window['go']['api']['Service']['SetEventSink'](arg1);
}
export function SetFavourite(arg1, arg2) {
return window['go']['api']['Service']['SetFavourite'](arg1, arg2);
}
export function SetProfileSecret(arg1, arg2, arg3) {
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
}
export function SetTOTPSecret(arg1, arg2) {
return window['go']['api']['Service']['SetTOTPSecret'](arg1, arg2);
}
export function Shutdown() {
return window['go']['api']['Service']['Shutdown']();
}
export function StartPortForward(arg1, arg2) {
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
}
export function StartRecording(arg1, arg2, arg3) {
return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3);
}
export function StopPortForward(arg1, arg2) {
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
}
export function StopRecording(arg1) {
return window['go']['api']['Service']['StopRecording'](arg1);
}
export function TestConnection(arg1, arg2) {
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
}
export function UnlockSecretStore(arg1) {
return window['go']['api']['Service']['UnlockSecretStore'](arg1);
}
export function UpdateProfile(arg1) {
return window['go']['api']['Service']['UpdateProfile'](arg1);
}
export function UpdateSnippet(arg1) {
return window['go']['api']['Service']['UpdateSnippet'](arg1);
}
export function WriteSession(arg1, arg2) {
return window['go']['api']['Service']['WriteSession'](arg1, arg2);
}
export function WriteSessionEscaped(arg1, arg2) {
return window['go']['api']['Service']['WriteSessionEscaped'](arg1, arg2);
}
export function WriteSessionHex(arg1, arg2) {
return window['go']['api']['Service']['WriteSessionHex'](arg1, arg2);
}
export function WriteSessionText(arg1, arg2) {
return window['go']['api']['Service']['WriteSessionText'](arg1, arg2);
}
-691
View File
@@ -1,691 +0,0 @@
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 class leviathanDocState {
name: string;
size: number;
dirty: boolean;
undoLen: number;
redoLen: number;
compare?: leviathanDocState;
previewHex: any[];
static createFrom(source: any = {}) {
return new leviathanDocState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.size = source["size"];
this.dirty = source["dirty"];
this.undoLen = source["undoLen"];
this.redoLen = source["redoLen"];
this.compare = this.convertValues(source["compare"], leviathanDocState);
this.previewHex = source["previewHex"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace models {
export class AppPreferences {
theme: string;
terminalTheme: string;
defaultProtocol: string;
confirmOnClose: boolean;
confirmOnDisconnect: boolean;
showStatusBar: boolean;
sidebarWidth: number;
defaultFontFamily: string;
defaultFontSize: number;
defaultScrollback: number;
defaultCursorStyle: string;
useNativePaste: boolean;
localShell: string;
leviathanEnabled: boolean;
static createFrom(source: any = {}) {
return new AppPreferences(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.theme = source["theme"];
this.terminalTheme = source["terminalTheme"];
this.defaultProtocol = source["defaultProtocol"];
this.confirmOnClose = source["confirmOnClose"];
this.confirmOnDisconnect = source["confirmOnDisconnect"];
this.showStatusBar = source["showStatusBar"];
this.sidebarWidth = source["sidebarWidth"];
this.defaultFontFamily = source["defaultFontFamily"];
this.defaultFontSize = source["defaultFontSize"];
this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
this.leviathanEnabled = source["leviathanEnabled"];
}
}
export class AuditEntry {
id: string;
timestamp: string;
action: string;
profileId?: string;
sessionId?: string;
details: string;
static createFrom(source: any = {}) {
return new AuditEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.timestamp = source["timestamp"];
this.action = source["action"];
this.profileId = source["profileId"];
this.sessionId = source["sessionId"];
this.details = source["details"];
}
}
export class GitHubConfig {
baseURL: string;
secretMode: string;
refreshIntervalSecs: number;
static createFrom(source: any = {}) {
return new GitHubConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.baseURL = source["baseURL"];
this.secretMode = source["secretMode"];
this.refreshIntervalSecs = source["refreshIntervalSecs"];
}
}
export class GitHubRepo {
fullName: string;
name: string;
owner: string;
description: string;
htmlURL: string;
defaultBranch: string;
latestCommitter: string;
latestCommitAt: string;
private: boolean;
fork: boolean;
archived: boolean;
language: string;
stars: number;
pushedAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new GitHubRepo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.fullName = source["fullName"];
this.name = source["name"];
this.owner = source["owner"];
this.description = source["description"];
this.htmlURL = source["htmlURL"];
this.defaultBranch = source["defaultBranch"];
this.latestCommitter = source["latestCommitter"];
this.latestCommitAt = source["latestCommitAt"];
this.private = source["private"];
this.fork = source["fork"];
this.archived = source["archived"];
this.language = source["language"];
this.stars = source["stars"];
this.pushedAt = source["pushedAt"];
this.updatedAt = source["updatedAt"];
}
}
export class IRCConfig {
nick: string;
realName: string;
useTLS: boolean;
serverPasswordRequired: boolean;
serverPasswordMode: string;
nickServIdentify: boolean;
nickServAccount: string;
nickServMode: string;
autoJoinRooms: string[];
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
static createFrom(source: any = {}) {
return new IRCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.nick = source["nick"];
this.realName = source["realName"];
this.useTLS = source["useTLS"];
this.serverPasswordRequired = source["serverPasswordRequired"];
this.serverPasswordMode = source["serverPasswordMode"];
this.nickServIdentify = source["nickServIdentify"];
this.nickServAccount = source["nickServAccount"];
this.nickServMode = source["nickServMode"];
this.autoJoinRooms = source["autoJoinRooms"];
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"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
}
}
export class JumpHost {
host: string;
port: number;
username: string;
authMethod: string;
secretMode: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new JumpHost(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.authMethod = source["authMethod"];
this.secretMode = source["secretMode"];
this.privateKeyPath = source["privateKeyPath"];
}
}
export class KnownHost {
id: string;
host: string;
port: number;
keyType: string;
fingerprint: string;
publicKey: string;
firstSeen: string;
lastSeen: string;
trusted: boolean;
static createFrom(source: any = {}) {
return new KnownHost(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.host = source["host"];
this.port = source["port"];
this.keyType = source["keyType"];
this.fingerprint = source["fingerprint"];
this.publicKey = source["publicKey"];
this.firstSeen = source["firstSeen"];
this.lastSeen = source["lastSeen"];
this.trusted = source["trusted"];
}
}
export class LoginScriptStep {
type: string;
value: string;
timeout: number;
static createFrom(source: any = {}) {
return new LoginScriptStep(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.value = source["value"];
this.timeout = source["timeout"];
}
}
export class PayloadPreset {
id: string;
name: string;
payload: string;
format: string;
static createFrom(source: any = {}) {
return new PayloadPreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.payload = source["payload"];
this.format = source["format"];
}
}
export class PortForward {
id: string;
direction: string;
bindHost: string;
bindPort: number;
destHost: string;
destPort: number;
enabled: boolean;
label: string;
static createFrom(source: any = {}) {
return new PortForward(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.direction = source["direction"];
this.bindHost = source["bindHost"];
this.bindPort = source["bindPort"];
this.destHost = source["destHost"];
this.destPort = source["destPort"];
this.enabled = source["enabled"];
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;
encoding: string;
showTimestamps: boolean;
showDirection: boolean;
payloadPresets: PayloadPreset[];
static createFrom(source: any = {}) {
return new RAWConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.autoReconnect = source["autoReconnect"];
this.lineEnding = source["lineEnding"];
this.encoding = source["encoding"];
this.showTimestamps = source["showTimestamps"];
this.showDirection = source["showDirection"];
this.payloadPresets = this.convertValues(source["payloadPresets"], PayloadPreset);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class TelnetConfig {
localEcho: boolean;
lineEnding: string;
terminalType: string;
encoding: string;
negotiationDebug: boolean;
lineMode: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
loginScripts: LoginScriptStep[];
static createFrom(source: any = {}) {
return new TelnetConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.localEcho = source["localEcho"];
this.lineEnding = source["lineEnding"];
this.terminalType = source["terminalType"];
this.encoding = source["encoding"];
this.negotiationDebug = source["negotiationDebug"];
this.lineMode = source["lineMode"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SSHConfig {
authMethods: string[];
secretMode: string;
privateKeyPath: string;
keySecretMode: string;
hostKeyPolicy: string;
terminalType: string;
keepaliveInterval: number;
keepaliveCountMax: number;
compression: boolean;
agentAuth: boolean;
agentForwarding: boolean;
autoReconnect: boolean;
maxReconnectTries: number;
loginScripts: LoginScriptStep[];
totpSecret: string;
proxyType: string;
proxyHost: string;
proxyPort: number;
proxyUsername: string;
proxyPassword: string;
jumpHosts: JumpHost[];
portForwards: PortForward[];
startupCommand: string;
envVars: Record<string, string>;
static createFrom(source: any = {}) {
return new SSHConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.authMethods = source["authMethods"];
this.secretMode = source["secretMode"];
this.privateKeyPath = source["privateKeyPath"];
this.keySecretMode = source["keySecretMode"];
this.hostKeyPolicy = source["hostKeyPolicy"];
this.terminalType = source["terminalType"];
this.keepaliveInterval = source["keepaliveInterval"];
this.keepaliveCountMax = source["keepaliveCountMax"];
this.compression = source["compression"];
this.agentAuth = source["agentAuth"];
this.agentForwarding = source["agentForwarding"];
this.autoReconnect = source["autoReconnect"];
this.maxReconnectTries = source["maxReconnectTries"];
this.loginScripts = this.convertValues(source["loginScripts"], LoginScriptStep);
this.totpSecret = source["totpSecret"];
this.proxyType = source["proxyType"];
this.proxyHost = source["proxyHost"];
this.proxyPort = source["proxyPort"];
this.proxyUsername = source["proxyUsername"];
this.proxyPassword = source["proxyPassword"];
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
this.portForwards = this.convertValues(source["portForwards"], PortForward);
this.startupCommand = source["startupCommand"];
this.envVars = source["envVars"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Profile {
id: string;
displayName: string;
description: string;
protocol: string;
host: string;
port: number;
username: string;
tags: string[];
folder: string;
environment: string;
notes: string;
favourite: boolean;
lastConnected?: string;
connectTimeout: number;
readTimeout: number;
createdAt: string;
updatedAt: string;
terminalFontFamily: string;
terminalFontSize: number;
terminalScrollback: number;
terminalCursorStyle: string;
ssh?: SSHConfig;
telnet?: TelnetConfig;
raw?: RAWConfig;
github?: GitHubConfig;
vnc?: VNCConfig;
irc?: IRCConfig;
static createFrom(source: any = {}) {
return new Profile(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.displayName = source["displayName"];
this.description = source["description"];
this.protocol = source["protocol"];
this.host = source["host"];
this.port = source["port"];
this.username = source["username"];
this.tags = source["tags"];
this.folder = source["folder"];
this.environment = source["environment"];
this.notes = source["notes"];
this.favourite = source["favourite"];
this.lastConnected = source["lastConnected"];
this.connectTimeout = source["connectTimeout"];
this.readTimeout = source["readTimeout"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.terminalFontFamily = source["terminalFontFamily"];
this.terminalFontSize = source["terminalFontSize"];
this.terminalScrollback = source["terminalScrollback"];
this.terminalCursorStyle = source["terminalCursorStyle"];
this.ssh = this.convertValues(source["ssh"], SSHConfig);
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);
this.irc = this.convertValues(source["irc"], IRCConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SessionInfo {
id: string;
profileId: string;
profileName: string;
protocol: string;
host: string;
port: number;
state: string;
tabName: string;
pinned: boolean;
connectedAt: string;
error?: string;
static createFrom(source: any = {}) {
return new SessionInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.profileId = source["profileId"];
this.profileName = source["profileName"];
this.protocol = source["protocol"];
this.host = source["host"];
this.port = source["port"];
this.state = source["state"];
this.tabName = source["tabName"];
this.pinned = source["pinned"];
this.connectedAt = source["connectedAt"];
this.error = source["error"];
}
}
export class Snippet {
id: string;
name: string;
command: string;
description: string;
folder: string;
tags: string[];
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new Snippet(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.command = source["command"];
this.description = source["description"];
this.folder = source["folder"];
this.tags = source["tags"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
}
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
-330
View File
@@ -1,330 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
-298
View File
@@ -1,298 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
-58
View File
@@ -1,58 +0,0 @@
module github.com/andydixon/link
go 1.26.0
toolchain go1.26.5
require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/klauspost/compress v1.17.11
github.com/lrstanley/girc v1.1.1
github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.56.0
golang.org/x/net v0.57.0
golang.org/x/text v0.41.0
modernc.org/sqlite v1.53.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.15.2 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-156
View File
@@ -1,156 +0,0 @@
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/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/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.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/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
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.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-2262
View File
File diff suppressed because it is too large Load Diff
-387
View File
@@ -1,387 +0,0 @@
package api
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/andydixon/link/internal/audit"
"github.com/andydixon/link/internal/leviathan/document"
"github.com/andydixon/link/internal/models"
"github.com/andydixon/link/internal/storage"
)
type closeTrackingWriter struct {
closed int
}
func (w *closeTrackingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w *closeTrackingWriter) Close() error {
w.closed++
return nil
}
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
privPath, pubPath, err := sshKeyPairPaths("deploy_key-01")
if err != nil {
t.Fatalf("sshKeyPairPaths() error = %v", err)
}
wantPriv := filepath.Join(home, ".ssh", "deploy_key-01")
if privPath != wantPriv {
t.Fatalf("private path = %q, want %q", privPath, wantPriv)
}
if pubPath != wantPriv+".pub" {
t.Fatalf("public path = %q, want %q", pubPath, wantPriv+".pub")
}
}
func TestSSHKeyPairPathsRejectsTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("../outside")
if err == nil {
t.Fatal("expected traversal key name to be rejected")
}
if !strings.Contains(err.Error(), "invalid characters") {
t.Fatalf("error = %q, want invalid characters", err.Error())
}
}
func TestSSHKeyPairPathsRejectsNestedPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
_, _, err := sshKeyPairPaths("nested/key")
if err == nil {
t.Fatal("expected nested key path to be rejected")
}
}
func TestRedactProfileSecretsClearsInlineSSHSecrets(t *testing.T) {
profile := models.NewProfile(models.ProtocolSSH)
profile.SSH.ProxyPassword = "proxy-secret"
profile.SSH.TOTPSecret = "totp-secret"
got := redactProfileSecrets([]*models.Profile{&profile})
if got[0].SSH.ProxyPassword != "" {
t.Fatalf("ProxyPassword = %q, want empty", got[0].SSH.ProxyPassword)
}
if got[0].SSH.TOTPSecret != "" {
t.Fatalf("TOTPSecret = %q, want empty", got[0].SSH.TOTPSecret)
}
if profile.SSH.ProxyPassword == "" || profile.SSH.TOTPSecret == "" {
t.Fatal("redaction mutated the original profile")
}
}
func TestCreateProfileRejectsUnsafeGitHubBaseURL(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolGitHub)
profile.DisplayName = "Unsafe GitHub"
profile.GitHub.BaseURL = "http://evil.example/api/v3"
if _, err := svc.CreateProfile(profile); err == nil {
t.Fatal("expected unsafe GitHub base URL to be rejected")
}
}
func TestImportProfilesRejectsNullEntry(t *testing.T) {
svc := newTestService(t)
_, err := svc.ImportProfiles(`[null]`, "skip")
if err == nil {
t.Fatal("expected null import entry to be rejected")
}
if !strings.Contains(err.Error(), "entry 0 is null") {
t.Fatalf("error = %q, want null entry detail", err.Error())
}
}
func TestImportProfilesRejectsOversizedJSON(t *testing.T) {
svc := newTestService(t)
oversized := strings.Repeat(" ", maxProfileImportJSONBytes+1)
_, err := svc.ImportProfiles(oversized, "skip")
if err == nil {
t.Fatal("expected oversized import JSON to be rejected")
}
}
func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxInlineSFTPUploadBytes+1))
err := svc.SFTPUploadData("missing-session", "/", "large.bin", payload)
if err == nil {
t.Fatal("expected oversized upload payload to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestCancelSFTPUploadsClosesOnlyMatchingSession(t *testing.T) {
first := &closeTrackingWriter{}
second := &closeTrackingWriter{}
svc := &Service{
uploads: map[string]*sftpUpload{
"first": {writer: first, sessionID: "session-1", remotePath: "/first"},
"second": {writer: second, sessionID: "session-2", remotePath: "/second"},
},
}
svc.cancelSFTPUploads("session-1")
if first.closed != 1 {
t.Fatalf("matching writer closed %d times, want 1", first.closed)
}
if second.closed != 0 {
t.Fatalf("unrelated writer closed %d times, want 0", second.closed)
}
if len(svc.uploads) != 1 || svc.uploads["second"] == nil {
t.Fatalf("remaining uploads = %#v, want only session-2", svc.uploads)
}
}
func TestSFTPUploadCapacityIsBounded(t *testing.T) {
svc := &Service{uploads: make(map[string]*sftpUpload)}
for i := 0; i < maxConcurrentSFTPUploads; i++ {
svc.uploads[string(rune('a'+i))] = &sftpUpload{writer: &closeTrackingWriter{}}
}
if err := svc.checkSFTPUploadCapacity(); err == nil {
t.Fatal("expected active upload limit to be enforced")
}
}
func TestServiceShutdownReleasesUploadsAndSecretWaiters(t *testing.T) {
writer := &closeTrackingWriter{}
waiter := make(chan secretResponse, 1)
svc := &Service{
uploads: map[string]*sftpUpload{"upload": {writer: writer}},
secretChans: map[string]chan secretResponse{"prompt": waiter},
}
svc.Shutdown()
if writer.closed != 1 {
t.Fatalf("writer closed %d times, want 1", writer.closed)
}
if len(svc.uploads) != 0 {
t.Fatalf("active uploads = %d, want 0", len(svc.uploads))
}
select {
case response := <-waiter:
if !response.cancelled {
t.Fatal("shutdown response was not cancelled")
}
default:
t.Fatal("shutdown did not release secret prompt waiter")
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) {
for _, name := range []string{"..", "folder/..", `folder\..`} {
t.Run(name, func(t *testing.T) {
if _, err := safeSFTPUploadName(name); err == nil {
t.Fatal("expected parent directory upload name to be rejected")
}
})
}
}
func TestValidateSSHKeyGenerationOptions(t *testing.T) {
keyType, bits, err := validateSSHKeyGenerationOptions("", 0)
if err != nil {
t.Fatalf("default options error = %v", err)
}
if keyType != "ed25519" {
t.Fatalf("default key type = %q, want ed25519", keyType)
}
if bits != 0 {
t.Fatalf("default bits = %d, want 0", bits)
}
keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0)
if err != nil {
t.Fatalf("rsa default options error = %v", err)
}
if keyType != "rsa" || bits != 4096 {
t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits)
}
if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil {
t.Fatal("expected oversized RSA key size to be rejected")
}
if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil {
t.Fatal("expected unsupported key type to be rejected")
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestLeviathanDocumentBytesRejectsOversizedDocument(t *testing.T) {
doc := document.New("large.bin", []byte("abcd"))
_, err := leviathanDocumentBytes(doc, 3, "test")
if err == nil {
t.Fatal("expected oversized document to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWriteLeviathanDocumentFileWritesPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcd"))
if _, err := doc.Set(1, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "out.bin")
if err := writeLeviathanDocumentFile(path, doc); err != nil {
t.Fatalf("writeLeviathanDocumentFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "aZcd" {
t.Fatalf("written bytes = %q, want %q", string(got), "aZcd")
}
}
func TestWriteLeviathanDocumentRangeFileWritesSelectedPatchedBytes(t *testing.T) {
doc := document.New("patched.bin", []byte("abcdef"))
if _, err := doc.Set(3, 'Z'); err != nil {
t.Fatalf("Set() failed: %v", err)
}
path := filepath.Join(t.TempDir(), "range.bin")
if err := writeLeviathanDocumentRangeFile(path, doc, 2, 3); err != nil {
t.Fatalf("writeLeviathanDocumentRangeFile() failed: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() failed: %v", err)
}
if string(got) != "cZe" {
t.Fatalf("written bytes = %q, want %q", string(got), "cZe")
}
}
func TestReadLeviathanSmallFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.img")
if err := os.WriteFile(path, []byte("abcd"), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readLeviathanSmallFile(path, 3, "image")
if err == nil {
t.Fatal("expected oversized file to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadSSHPublicKeyFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "big.pub")
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxSSHPublicKeyBytes+1)), 0600); err != nil {
t.Fatalf("WriteFile() failed: %v", err)
}
_, err := readSSHPublicKeyFile(path)
if err == nil {
t.Fatal("expected oversized public key to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
t.Cleanup(func() { store.Close() })
return NewService(store, nil, nil, nil, audit.NewLogger(store), nil)
}

Some files were not shown because too many files have changed in this diff Show More