Remove the Go backend, JS frontend, Wails config and old build scripts; release.sh now wraps rust/release.sh
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)">>_</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 |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 74 KiB |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 364 KiB |
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
24ecf1ed6af3b41cf2f6d8fa0bc732ec
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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 };
|
||||
@@ -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.");
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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">×</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">×</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">×</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">×</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;
|
||||
|
||||
})();
|
||||
@@ -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">×</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">×</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">×</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;
|
||||
|
||||
})();
|
||||
@@ -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">×</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">×</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">×</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">×</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;
|
||||
})();
|
||||
@@ -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">×</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">×</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;
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -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">×</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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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">×</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;
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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">×</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;
|
||||
})();
|
||||
@@ -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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
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 });
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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());
|
||||
})();
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||