Phase 4: SFTP file browser, SSH config import, and update checking
SFTP file browser: - Created internal/sftpclient wrapping github.com/pkg/sftp for remote file operations (list, stat, mkdir, remove, rename, download, upload, pwd) - Integrated SFTP into session manager with lazy client creation per session - Added API methods for all SFTP operations with native file dialogs - Built SFTPBrowser frontend component — right-side resizable panel with breadcrumb navigation, sorted file listing, context actions (download, rename, delete), upload, new folder, and custom modal dialogs - Toggle via Cmd+Shift+F on active SSH sessions - Auto-refits terminal when panel opens/closes/resizes - Panel cleaned up automatically when session closes OpenSSH config import: - Created internal/sshconfig parser for ~/.ssh/config Host blocks - Converts to profiles with hostname, port, user, identity file, ProxyJump (first hop), ForwardAgent, and Compression support - Skips wildcard-only hosts and Match blocks - Import button added to Settings > About tab Automatic update checking: - Created internal/updater querying GitHub Releases API - Simple semver comparison, 10-second timeout - Dev builds never trigger update prompts - Check button added to Settings > About tab
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Package updater checks for new application releases by querying the
|
||||
// GitHub Releases API. It compares the latest published tag against the
|
||||
// current build version and reports whether an update is available.
|
||||
//
|
||||
// The check is non-blocking and opt-in — it runs in the background and
|
||||
// does not download or install anything automatically.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultOwner and defaultRepo identify the GitHub repository to check.
|
||||
// These should be overridden via SetRepository if the project is hosted
|
||||
// elsewhere.
|
||||
defaultOwner = "nexus-terminal"
|
||||
defaultRepo = "nexus-terminal"
|
||||
|
||||
// requestTimeout limits the HTTP request duration.
|
||||
requestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Release describes a GitHub release with the fields we care about.
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Body string `json:"body"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
// UpdateInfo carries the result of an update check.
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseURL string `json:"releaseURL"`
|
||||
ReleaseName string `json:"releaseName"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
|
||||
// Checker performs version checks against GitHub Releases.
|
||||
type Checker struct {
|
||||
owner string
|
||||
repo string
|
||||
current string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewChecker creates an update checker for the given current version.
|
||||
// The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3").
|
||||
func NewChecker(currentVersion string) *Checker {
|
||||
return &Checker{
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
current: normaliseVersion(currentVersion),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetRepository overrides the GitHub repository to check.
|
||||
func (c *Checker) SetRepository(owner, repo string) {
|
||||
c.owner = owner
|
||||
c.repo = repo
|
||||
}
|
||||
|
||||
// Check queries the GitHub API for the latest release and compares it to
|
||||
// the current version. Returns UpdateInfo describing the result.
|
||||
func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", c.owner, c.repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updater: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NexusTerminal-UpdateChecker")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updater: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// No releases exist yet — not an error.
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: c.current,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("updater: GitHub API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("updater: failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Skip drafts and pre-releases.
|
||||
if release.Draft || release.Prerelease {
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: normaliseVersion(release.TagName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
latest := normaliseVersion(release.TagName)
|
||||
available := isNewer(latest, c.current)
|
||||
|
||||
return &UpdateInfo{
|
||||
Available: available,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: latest,
|
||||
ReleaseURL: release.HTMLURL,
|
||||
ReleaseName: release.Name,
|
||||
ReleaseNotes: truncateNotes(release.Body, 500),
|
||||
PublishedAt: release.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normaliseVersion strips a leading "v" and trims whitespace.
|
||||
func normaliseVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
return v
|
||||
}
|
||||
|
||||
// isNewer performs a simple string comparison of dot-separated version parts.
|
||||
// Returns true if latest > current (basic semver, no pre-release handling).
|
||||
func isNewer(latest, current string) bool {
|
||||
if current == "" || current == "dev" {
|
||||
return false // dev builds never trigger update prompts
|
||||
}
|
||||
|
||||
lParts := strings.Split(latest, ".")
|
||||
cParts := strings.Split(current, ".")
|
||||
|
||||
maxLen := len(lParts)
|
||||
if len(cParts) > maxLen {
|
||||
maxLen = len(cParts)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var lv, cv int
|
||||
if i < len(lParts) {
|
||||
fmt.Sscanf(lParts[i], "%d", &lv)
|
||||
}
|
||||
if i < len(cParts) {
|
||||
fmt.Sscanf(cParts[i], "%d", &cv)
|
||||
}
|
||||
if lv > cv {
|
||||
return true
|
||||
}
|
||||
if lv < cv {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateNotes trims release notes to a maximum length.
|
||||
func truncateNotes(body string, maxLen int) string {
|
||||
if len(body) <= maxLen {
|
||||
return body
|
||||
}
|
||||
return body[:maxLen] + "…"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormaliseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"v1.2.3", "1.2.3"},
|
||||
{"1.2.3", "1.2.3"},
|
||||
{" v0.5.0 ", "0.5.0"},
|
||||
{"dev", "dev"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normaliseVersion(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normaliseVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
latest string
|
||||
current string
|
||||
want bool
|
||||
}{
|
||||
{"1.1.0", "1.0.0", true},
|
||||
{"2.0.0", "1.9.9", true},
|
||||
{"1.0.1", "1.0.0", true},
|
||||
{"1.0.0", "1.0.0", false},
|
||||
{"0.9.0", "1.0.0", false},
|
||||
{"1.0.0", "dev", false}, // dev builds ignored
|
||||
{"1.0.0", "", false}, // empty current ignored
|
||||
{"1.2", "1.1.0", true}, // fewer parts
|
||||
{"1.0.0", "1.0.0.1", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.latest+" vs "+tt.current, func(t *testing.T) {
|
||||
got := isNewer(tt.latest, tt.current)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNewer(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateNotes(t *testing.T) {
|
||||
short := "Short note"
|
||||
if got := truncateNotes(short, 100); got != short {
|
||||
t.Errorf("expected %q, got %q", short, got)
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", 600)
|
||||
got := truncateNotes(long, 500)
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("expected truncated notes to end with ellipsis")
|
||||
}
|
||||
if len([]rune(got)) != 501 { // 500 runes + "…"
|
||||
t.Errorf("expected 501 runes, got %d", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user