diff --git a/README.md b/README.md index 98f36ef..b4698a6 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,36 @@ go test ./internal/storage/ -v -count=1 -run TestProfileCRUD The frontend has no separate build step — `npm run build` (called by Wails) copies xterm vendor libraries from `node_modules` into the frontend source tree. All frontend assets are embedded at compile time. +### Cross-Build Helper (macOS/Linux) + +If you want to build multiple targets from a macOS or Linux host, use the helper script: + +```bash +./build-all.sh +``` + +It builds the host platform plus Linux and Windows (both `amd64` and `arm64`) **where a suitable C compiler is available**. SQLite requires cgo, so missing toolchains will cause a target to be skipped. Common compiler names: + +- Linux: `x86_64-linux-gnu-gcc`, `aarch64-linux-gnu-gcc` +- Windows: `x86_64-w64-mingw32-gcc`, `aarch64-w64-mingw32-gcc` + +### Windows Builds + +SQLite requires cgo on Windows, which means you need a C compiler in `PATH` (for example MSYS2 with mingw-w64). Use the Windows build script to set `CGO_ENABLED=1` and run the right steps: + +```powershell +.\build.ps1 deps +.\build.ps1 build +``` + +If the build fails with a cgo/SQLite error, install MSYS2, then install the toolchain: + +```powershell +pacman -S --needed mingw-w64-x86_64-toolchain +``` + +Then reopen your terminal so `gcc` is on `PATH` and retry `build.ps1`. + --- ## Data Locations diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 0000000..3ac9f57 --- /dev/null +++ b/build-all.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# build-all.sh — Cross-build helper for Nexus Terminal (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" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${GREEN}[nexus]${NC} $1"; } +warn() { echo -e "${YELLOW}[nexus]${NC} $1"; } +err() { echo -e "${RED}[nexus]${NC} $1" >&2; } + +check_deps() { + command -v go >/dev/null 2>&1 || { err "Go is not installed"; exit 1; } + command -v node >/dev/null 2>&1 || { err "Node.js is not installed"; exit 1; } + command -v npm >/dev/null 2>&1 || { err "npm is not installed"; exit 1; } + + if ! command -v wails >/dev/null 2>&1; then + if [ -f "$HOME/go/bin/wails" ]; then + export PATH="$PATH:$HOME/go/bin" + else + err "Wails is not installed. Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest" + exit 1 + fi + fi + + log "Go: $(go version)" + log "Node: $(node --version)" + log "Wails: $(wails version 2>/dev/null | head -1)" +} + +install_deps() { + log "Installing Go dependencies…" + go mod download + + log "Installing frontend dependencies…" + cd frontend && npm install && cd .. +} + +host_os() { + case "$(uname -s)" in + Darwin) echo "darwin" ;; + Linux) echo "linux" ;; + *) echo "unknown" ;; + esac +} + +pick_cc() { + local target_os="$1" + local target_arch="$2" + + if [ "$target_os" = "windows" ]; then + if [ "$target_arch" = "amd64" ]; then + echo "x86_64-w64-mingw32-gcc" + else + echo "aarch64-w64-mingw32-gcc" + fi + return + fi + + if [ "$target_os" = "linux" ]; then + if [ "$target_arch" = "amd64" ]; then + echo "x86_64-linux-gnu-gcc" + else + echo "aarch64-linux-gnu-gcc" + fi + return + fi + + echo "" +} + +build_target() { + local target_os="$1" + local target_arch="$2" + local host="$3" + + local platform="${target_os}/${target_arch}" + local cc="" + + if [ "$target_os" != "$host" ]; then + cc="$(pick_cc "$target_os" "$target_arch")" + if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then + warn "Skipping ${platform} (missing C compiler: ${cc})" + return 0 + fi + fi + + log "Building ${platform}…" + if [ -n "$cc" ]; then + CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" + else + CGO_ENABLED=1 wails build -platform "$platform" + fi +} + +main() { + check_deps + install_deps + + local host + host="$(host_os)" + if [ "$host" = "unknown" ]; then + err "Unsupported host OS for this script. Use macOS or Linux." + exit 1 + fi + + # Build host targets first + build_target "$host" "amd64" "$host" + build_target "$host" "arm64" "$host" + + # Cross-build Linux and Windows targets where toolchains are available + if [ "$host" = "darwin" ] || [ "$host" = "linux" ]; then + build_target "linux" "amd64" "$host" + build_target "linux" "arm64" "$host" + build_target "windows" "amd64" "$host" + build_target "windows" "arm64" "$host" + fi + + log "Builds complete. Output: build/bin/" +} + +main "$@" diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..9e72d22 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,96 @@ +# build.ps1 — Windows build script for Nexus Terminal +# Usage: .\build.ps1 [dev|build|test|clean|deps] +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info($Message) { Write-Host "[nexus] $Message" -ForegroundColor Green } +function Write-Warn($Message) { Write-Host "[nexus] $Message" -ForegroundColor Yellow } +function Write-Err($Message) { Write-Host "[nexus] $Message" -ForegroundColor Red } + +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)" + + if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { + Write-Warn 'gcc not found. SQLite requires cgo + a C compiler (e.g. MSYS2 mingw-w64).' + } + + 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 Dev { + $wailsPath = Check-Deps + Install-Deps + Write-Info 'Starting development mode...' + & $wailsPath dev +} + +function Build { + $wailsPath = Check-Deps + Install-Deps + + $env:CGO_ENABLED = '1' + Write-Info 'Building production binary (CGO_ENABLED=1)...' + & $wailsPath build + 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 + } +} diff --git a/frontend/index.html b/frontend/index.html index 48e244f..f0f876f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -193,6 +193,7 @@ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b9df2d5..9c7bd91 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "xterm": "5.3.0", "xterm-addon-fit": "0.8.0", "xterm-addon-search": "0.13.0", + "xterm-addon-unicode11": "0.6.0", "xterm-addon-web-links": "0.9.0" } }, @@ -42,6 +43,16 @@ "xterm": "^5.0.0" } }, + "node_modules/xterm-addon-unicode11": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz", + "integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/xterm-addon-web-links": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8b85a57..a1e190f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "xterm": "5.3.0", "xterm-addon-fit": "0.8.0", "xterm-addon-search": "0.13.0", + "xterm-addon-unicode11": "0.6.0", "xterm-addon-web-links": "0.9.0" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 9ceaca7..4125fc7 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a25ea4f4df93e50eb6c41092b5187a9d \ No newline at end of file +ce0d75b28075a7688ba4535c94b4784c \ No newline at end of file diff --git a/frontend/scripts/copy-assets.js b/frontend/scripts/copy-assets.js index dabb081..db8d17b 100644 --- a/frontend/scripts/copy-assets.js +++ b/frontend/scripts/copy-assets.js @@ -29,6 +29,10 @@ const assets = [ src: 'xterm-addon-search/lib/xterm-addon-search.js', dest: 'src/js/lib/xterm-addon-search.min.js', }, + { + src: 'xterm-addon-unicode11/lib/xterm-addon-unicode11.js', + dest: 'src/js/lib/xterm-addon-unicode11.min.js', + }, { src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js', dest: 'src/js/lib/xterm-addon-web-links.min.js', diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 482ab78..7c1e7e8 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -483,6 +483,23 @@ class App { return; } + const existingSessions = this.state.getAllSessions().filter((session) => { + if (session.profileId !== profileId) return false; + return session.state === 'connected' || session.state === 'connecting' || session.state === 'reconnecting'; + }); + if (existingSessions.length > 0) { + const preferred = existingSessions.find((s) => s.id === this.state.activeSessionId) || existingSessions[0]; + const decision = await this._confirmReuseSession(profile, preferred); + if (decision === 'existing') { + this.tabs.setActive(preferred.id); + if (preferred.protocol !== 'raw' && preferred.protocol !== 'github') { + this.terminals.focusTerminal(preferred.id); + } + return; + } + if (decision !== 'new') return; + } + try { const sessionInfo = await window.go.api.Service.ConnectSession(profileId); this.state.addSession(sessionInfo); @@ -628,6 +645,60 @@ class App { } } + /** + * Prompts to reuse an existing session for a profile. + * @param {Object} profile + * @param {Object} session + * @returns {Promise<'existing'|'new'|null>} + * @private + */ + _confirmReuseSession(profile, session) { + const createElement = window.NexusTerminal.createElement; + const label = (profile.displayName || profile.host || 'Untitled').trim(); + const stateLabel = session.state === 'connected' ? 'connected' : 'connecting'; + + return new Promise((resolve) => { + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-sm'); + modal.innerHTML = ` + + + + `; + 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(null)); + modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null)); + modal.querySelector('.confirm-ok').addEventListener('click', () => close('existing')); + modal.querySelector('.confirm-new').addEventListener('click', () => close('new')); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); }); + }); + } + // ── Import / Export ────────────────────────────────────────────── /** diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index fd48f0a..8976372 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -5,7 +5,7 @@ * Handles data routing, resizing, and search. */ -/* global Terminal, FitAddon, SearchAddon, WebLinksAddon */ +/* global Terminal, FitAddon, SearchAddon, WebLinksAddon, Unicode11Addon */ class TerminalManager { /** @@ -36,7 +36,7 @@ class TerminalManager { const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal'; const term = new Terminal({ theme: window.NexusTerminal.getTerminalTheme(themeName), - fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', + fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace', fontSize: profile.terminalFontSize || 14, scrollback: profile.terminalScrollback || 10000, cursorStyle: profile.terminalCursorStyle || 'block', @@ -50,10 +50,19 @@ class TerminalManager { const fitAddon = new FitAddon.FitAddon(); const searchAddon = new SearchAddon.SearchAddon(); let webLinksAddon = null; + let unicode11Addon = null; term.loadAddon(fitAddon); term.loadAddon(searchAddon); + try { + unicode11Addon = new Unicode11Addon.Unicode11Addon(); + term.loadAddon(unicode11Addon); + term.unicode.activeVersion = '11'; + } catch (_) { + // Unicode 11 addon is optional; fall back to default unicode handling. + } + // WebLinksAddon may not always be available try { webLinksAddon = new WebLinksAddon.WebLinksAddon((_event, uri) => { @@ -68,6 +77,19 @@ class TerminalManager { const container = document.getElementById(containerId); term.open(container); + let suppressPasteEvent = false; + const attachPasteGuard = () => { + const inputEl = term.textarea || container.querySelector('textarea'); + if (!inputEl) return; + inputEl.addEventListener('paste', (e) => { + if (!suppressPasteEvent) return; + e.preventDefault(); + e.stopPropagation(); + suppressPasteEvent = false; + }); + }; + attachPasteGuard(); + // Right-click context menu on the terminal viewport const termEl = container.querySelector('.xterm'); if (termEl) { @@ -131,6 +153,8 @@ class TerminalManager { // Cmd/Ctrl+V — paste from clipboard if (mod && e.key === 'v' && e.type === 'keydown') { + suppressPasteEvent = true; + setTimeout(() => { suppressPasteEvent = false; }, 250); if (window.runtime && window.runtime.ClipboardGetText) { window.runtime.ClipboardGetText().then(function(text) { if (text) term.paste(text);