From 71855bf4e73eeb007e4ad8354072db2f45e38585 Mon Sep 17 00:00:00 2001 From: Gary Gan Date: Thu, 2 Jul 2026 18:22:27 +0800 Subject: [PATCH] feat: add mouse support and update install flow --- .goreleaser.yaml | 2 + README.md | 45 ++++- cmd/ktui/main.go | 28 +++ install.ps1 | 110 +++++++++++ install.sh | 144 ++++++++++++++ internal/tui/app.go | 60 +++++- internal/tui/app_test.go | 54 ++++++ internal/tui/frame.go | 28 ++- internal/tui/keys.go | 109 ++++++++++- internal/tui/mouse.go | 331 +++++++++++++++++++++++++++++++++ internal/tui/mouse_test.go | 202 ++++++++++++++++++++ internal/tui/sheet.go | 19 +- internal/tui/term_unix.go | 4 +- internal/tui/term_windows.go | 4 +- internal/update/update.go | 43 +++++ internal/update/update_test.go | 68 ++++++- 16 files changed, 1213 insertions(+), 38 deletions(-) create mode 100644 install.ps1 create mode 100755 install.sh create mode 100644 internal/tui/mouse.go create mode 100644 internal/tui/mouse_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a9f514c..199d8ef 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -36,6 +36,8 @@ archives: files: - README.md - LICENSE + - install.sh + - install.ps1 checksum: name_template: checksums.txt diff --git a/README.md b/README.md index 4be4505..7957f0d 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,43 @@ https://www.nodeseek.com/post-710243-1 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 - 支持配置文件,默认持久化到系统用户配置目录 - 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动 +- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口 - 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端 +- 支持远程一行安装脚本,自动识别系统和 CPU 架构并下载对应 Release -## 构建 +## 安装 + +Linux / macOS: + +```sh +curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | sh +``` + +Windows PowerShell: + +```powershell +irm https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.ps1 | iex +``` + +默认安装位置: + +- Linux / macOS:`~/.local/bin/ktui` +- Windows:`%LOCALAPPDATA%\ktui\bin\ktui.exe` + +安装指定版本或目录: + +```sh +curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_VERSION=v0.1.0 sh +curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_INSTALL_DIR=/usr/local/bin sh +``` + +私有仓库或需要认证的 Release 可以传 token: + +```sh +curl -fsSL https://gitea.bytevibe.dev/gary/ktui/raw/branch/main/install.sh | KTUI_UPDATE_TOKEN=your_token sh +``` + +## 从源码构建 需要 Go 1.26 或更新版本。 @@ -65,6 +99,12 @@ go run ./cmd/ktui ./ktui update ``` +进入 TUI 时会自动在后台检查是否有新版本。发现新版本后,界面顶部会提示 `UPDATE ... available`,底部会出现 `u update`;按 `u` 或点击该提示会显示更新命令。实际更新请退出 TUI 后运行: + +```sh +ktui update +``` + 指定 Komari 地址: ```sh @@ -208,6 +248,8 @@ EXP - - `↑` / `k`:列表页选择上一个节点;详情页向上滚动一张卡片 - `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片 +- 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项 +- 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回 - `PgUp` / `PgDn`:快速滚动列表或详情页 - `Enter` / `o`:打开选中节点的详情页 - `Esc` / `b` / `q`:从详情页返回列表页 @@ -217,6 +259,7 @@ EXP - - `r`:立即刷新 - `d`:打开或重新加载选中节点的详情数据 - `a`:切换 ASCII 兼容模式 +- `u`:有新版本时显示更新命令 - `q` / `Ctrl-C`:在列表页退出 ## 版本信息 diff --git a/cmd/ktui/main.go b/cmd/ktui/main.go index ef36f51..39d4448 100644 --- a/cmd/ktui/main.go +++ b/cmd/ktui/main.go @@ -158,6 +158,7 @@ func main() { RealtimePoints: realtimePoints, ChartYAxisMode: chartYAxis, SaveSettings: saveTUISettings, + CheckUpdate: checkSoftwareUpdate, ASCII: ascii, NoColor: noColor, Mode: mode, @@ -219,6 +220,22 @@ func saveTUISettings(settings tui.PersistentSettings) error { return err } +func checkSoftwareUpdate(ctx context.Context) (tui.UpdateCheckResult, error) { + result, err := update.Check(ctx, update.Options{ + CurrentVersion: version, + Timeout: 8 * time.Second, + }) + if err != nil { + return tui.UpdateCheckResult{}, err + } + return tui.UpdateCheckResult{ + CurrentVersion: result.CurrentVersion, + LatestVersion: result.LatestVersion, + AssetName: result.AssetName, + Available: result.Available, + }, nil +} + func handleConfig(args []string) error { if len(args) == 0 { printConfigHelp() @@ -453,6 +470,9 @@ func printKeysHelp() { List layer: Up/k, Down/j select server + Mouse wheel select previous/next server + Mouse click open server detail + Footer click settings/mode/refresh/ascii/quit PgUp, PgDn jump faster Enter/o open selected server detail s open settings @@ -460,19 +480,27 @@ List layer: r refresh now d open or reload selected server detail data a toggle ASCII compatibility mode + u show update command when an update is available q, Ctrl-C quit Detail layer: Esc, b, q return to list layer + Mouse click Back return to list layer h/l, 1-5, Tab switch detail tabs [, ] switch time window Up/k, Down/j scroll one card + Mouse wheel scroll detail cards + Mouse click switch tabs or time window + Footer click Back/settings/refresh s open settings + u show update command when an update is available PgUp, PgDn scroll faster Settings layer: Esc, q, s return to previous layer Up/k, Down/j select setting + Mouse wheel/click select setting + Footer click back/adjust/toggle Left/h, Right/l adjust value Enter toggle or advance value `) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e77aa72 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,110 @@ +$ErrorActionPreference = "Stop" + +$ApiBaseUrl = if ($env:KTUI_API_BASE_URL) { $env:KTUI_API_BASE_URL } else { "https://gitea.bytevibe.dev/api/v1/repos/gary/ktui" } +$InstallDir = if ($env:KTUI_INSTALL_DIR) { $env:KTUI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "ktui\bin" } +$Version = if ($env:KTUI_VERSION) { $env:KTUI_VERSION } else { "latest" } +$BinaryName = "ktui.exe" + +function Fail($Message) { + Write-Error "ktui install: $Message" + exit 1 +} + +function Invoke-KtuiGet($Url, $OutFile) { + $headers = @{} + if ($env:KTUI_UPDATE_TOKEN) { + $headers["Authorization"] = "token $env:KTUI_UPDATE_TOKEN" + } elseif ($env:GITEA_TOKEN) { + $headers["Authorization"] = "token $env:GITEA_TOKEN" + } + if ($OutFile) { + Invoke-WebRequest -UseBasicParsing -Headers $headers -Uri $Url -OutFile $OutFile + } else { + Invoke-RestMethod -Headers $headers -Uri $Url + } +} + +function Get-KtuiArch { + switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "amd64"; break } + "ARM64" { "arm64"; break } + default { Fail "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } + } +} + +$endpoint = "$ApiBaseUrl/releases/latest" +if ($Version -ne "latest") { + $endpoint = "$ApiBaseUrl/releases/tags/$Version" +} + +$release = Invoke-KtuiGet $endpoint +if (-not $release.tag_name) { + Fail "release response does not include tag_name" +} + +$arch = Get-KtuiArch +$suffix = "_windows_${arch}.zip" +$asset = $release.assets | Where-Object { + $_.name -like "ktui_*" -and $_.name.EndsWith($suffix) -and $_.browser_download_url +} | Select-Object -First 1 +$checksums = $release.assets | Where-Object { + $_.name -eq "checksums.txt" -and $_.browser_download_url +} | Select-Object -First 1 + +if (-not $asset) { + Fail "release $($release.tag_name) does not contain an asset for windows/$arch" +} +if (-not $checksums) { + Fail "release does not contain checksums.txt" +} + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ktui-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + $archivePath = Join-Path $tmp $asset.name + $checksumsPath = Join-Path $tmp "checksums.txt" + + Write-Host "ktui install: downloading $($asset.name)" + Invoke-KtuiGet $asset.browser_download_url $archivePath + Invoke-KtuiGet $checksums.browser_download_url $checksumsPath + + $expected = $null + foreach ($line in Get-Content $checksumsPath) { + $parts = $line -split "\s+" + if ($parts.Length -lt 2) { + continue + } + $filename = Split-Path ($parts[-1].TrimStart("*")) -Leaf + if ($filename -eq $asset.name) { + $expected = $parts[0].ToLowerInvariant() + break + } + } + if (-not $expected) { + Fail "checksums.txt does not contain $($asset.name)" + } + + $got = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + if ($got -ne $expected) { + Fail "checksum mismatch for $($asset.name)" + } + + Expand-Archive -Force -Path $archivePath -DestinationPath $tmp + $binary = Get-ChildItem -Path $tmp -Recurse -File -Filter $BinaryName | Select-Object -First 1 + if (-not $binary) { + Fail "archive does not contain $BinaryName" + } + + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + $installPath = Join-Path $InstallDir $BinaryName + Copy-Item -Force $binary.FullName $installPath + + Write-Host "ktui install: installed $installPath" + $pathEntries = $env:PATH -split ";" + if ($pathEntries -notcontains $InstallDir) { + Write-Host "ktui install: add $InstallDir to PATH if ktui is not found" + } + & $installPath version +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..735f079 --- /dev/null +++ b/install.sh @@ -0,0 +1,144 @@ +#!/bin/sh +set -eu + +API_BASE_URL="${KTUI_API_BASE_URL:-https://gitea.bytevibe.dev/api/v1/repos/gary/ktui}" +INSTALL_DIR="${KTUI_INSTALL_DIR:-$HOME/.local/bin}" +VERSION="${KTUI_VERSION:-latest}" +BINARY_NAME="ktui" + +fail() { + printf 'ktui install: %s\n' "$*" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +http_get() { + if [ -n "${KTUI_UPDATE_TOKEN:-}" ]; then + curl -fsSL -H "Authorization: token $KTUI_UPDATE_TOKEN" "$1" + elif [ -n "${GITEA_TOKEN:-}" ]; then + curl -fsSL -H "Authorization: token $GITEA_TOKEN" "$1" + else + curl -fsSL "$1" + fi +} + +detect_os() { + case "$(uname -s)" in + Linux) printf linux ;; + Darwin) printf darwin ;; + *) fail "unsupported OS: $(uname -s)" ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64 | amd64) printf amd64 ;; + arm64 | aarch64) printf arm64 ;; + *) fail "unsupported architecture: $(uname -m)" ;; + esac +} + +json_value() { + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 +} + +asset_url() { + name="$1" + printf '%s' "$release_json" | + tr '{' '\n' | + grep "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" | + sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n 1 +} + +checksum_for() { + name="$1" + awk -v name="$name" ' + { + file = $NF + sub(/^\*/, "", file) + n = split(file, parts, "/") + if (parts[n] == name) { + print tolower($1) + exit + } + } + ' "$2" +} + +verify_sha256() { + file="$1" + expected="$2" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$file" | awk '{print tolower($1)}')" + elif command -v shasum >/dev/null 2>&1; then + got="$(shasum -a 256 "$file" | awk '{print tolower($1)}')" + else + fail "missing sha256sum or shasum" + fi + [ "$got" = "$expected" ] || fail "checksum mismatch for $(basename "$file")" +} + +need curl +need tar +need awk +need sed +need grep + +os="$(detect_os)" +arch="$(detect_arch)" +endpoint="$API_BASE_URL/releases/latest" +if [ "$VERSION" != "latest" ]; then + endpoint="$API_BASE_URL/releases/tags/$VERSION" +fi + +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/ktui-install.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT INT TERM + +release_json="$(http_get "$endpoint")" +tag="$(json_value "$release_json" tag_name)" +[ -n "$tag" ] || fail "release response does not include tag_name" + +asset_name="$(printf '%s' "$release_json" | + tr '{' '\n' | + sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"\(ktui_[^\"]*_${os}_${arch}\.tar\.gz\)\".*/\1/p" | + head -n 1)" +[ -n "$asset_name" ] || fail "release $tag does not contain an asset for $os/$arch" + +archive_url="$(asset_url "$asset_name")" +checksums_url="$(asset_url checksums.txt)" +[ -n "$archive_url" ] || fail "release asset $asset_name has no download URL" +[ -n "$checksums_url" ] || fail "release does not contain checksums.txt" + +archive_path="$tmp_dir/$asset_name" +checksums_path="$tmp_dir/checksums.txt" + +printf 'ktui install: downloading %s\n' "$asset_name" +http_get "$archive_url" >"$archive_path" +http_get "$checksums_url" >"$checksums_path" + +expected="$(checksum_for "$asset_name" "$checksums_path")" +[ -n "$expected" ] || fail "checksums.txt does not contain $asset_name" +verify_sha256 "$archive_path" "$expected" + +tar -xzf "$archive_path" -C "$tmp_dir" +[ -f "$tmp_dir/$BINARY_NAME" ] || fail "archive does not contain $BINARY_NAME" + +mkdir -p "$INSTALL_DIR" +install_path="$INSTALL_DIR/$BINARY_NAME" +if command -v install >/dev/null 2>&1; then + install -m 0755 "$tmp_dir/$BINARY_NAME" "$install_path" +else + cp "$tmp_dir/$BINARY_NAME" "$install_path" + chmod 0755 "$install_path" +fi + +printf 'ktui install: installed %s\n' "$install_path" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) printf 'ktui install: add %s to PATH if ktui is not found\n' "$INSTALL_DIR" ;; +esac +"$install_path" version diff --git a/internal/tui/app.go b/internal/tui/app.go index 0b42f17..06111cd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -16,6 +16,7 @@ type App struct { detailCacheTTL time.Duration realtimePoints int saveSettings func(PersistentSettings) error + checkUpdate func(context.Context) (UpdateCheckResult, error) style Style mode Mode @@ -32,11 +33,13 @@ type App struct { settingsURL string settingsAPIKey string chartYAxisMode chartYAxisMode + notice string snapshot komari.Snapshot err error loading bool fetching bool + update updateState // At most one refresh is queued while an in-flight request is finishing. refreshPending bool intervalChanged bool @@ -53,6 +56,7 @@ type App struct { refreshCh chan struct{} resultCh chan fetchResult detailCh chan detailResult + updateCh chan updateResult keyCh chan keyEvent } @@ -66,11 +70,19 @@ type Options struct { RealtimePoints int ChartYAxisMode string SaveSettings func(PersistentSettings) error + CheckUpdate func(context.Context) (UpdateCheckResult, error) ASCII bool NoColor bool Mode Mode } +type UpdateCheckResult struct { + CurrentVersion string + LatestVersion string + AssetName string + Available bool +} + type PersistentSettings struct { Interval string Timeout string @@ -117,6 +129,20 @@ type detailResult struct { detail nodeDetail } +type updateResult struct { + result UpdateCheckResult + err error +} + +type updateState struct { + Checking bool + Checked bool + Available bool + Latest string + AssetName string + Err error +} + type detailKey struct { UUID string Window int @@ -124,6 +150,8 @@ type detailKey struct { type keyEvent struct { name string + x int + y int } var tabNames = []string{"overview", "node", "history", "ping", "meta"} @@ -209,6 +237,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App { detailTimeout: opts.DetailTimeout, detailCacheTTL: opts.DetailCacheTTL, realtimePoints: opts.RealtimePoints, + checkUpdate: opts.CheckUpdate, settingsURL: opts.URL, settingsAPIKey: opts.APIKey, style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor}, @@ -217,6 +246,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App { refreshCh: make(chan struct{}, 2), resultCh: make(chan fetchResult, 2), detailCh: make(chan detailResult, 4), + updateCh: make(chan updateResult, 1), keyCh: make(chan keyEvent, 16), loading: true, nodeDetail: map[detailKey]nodeDetail{}, @@ -238,6 +268,7 @@ func (a *App) Run(ctx context.Context) error { defer stopResize() go a.readKeys(ctx) + a.startUpdateCheck(ctx) a.requestRefresh() ticker := time.NewTicker(a.refreshInterval) @@ -289,6 +320,19 @@ func (a *App) Run(ctx context.Context) error { case detail := <-a.detailCh: a.nodeDetail[detail.key] = detail.detail a.render() + case update := <-a.updateCh: + a.update.Checking = false + a.update.Checked = true + a.update.Err = update.err + if update.err == nil { + a.update.Available = update.result.Available + a.update.Latest = update.result.LatestVersion + a.update.AssetName = update.result.AssetName + if update.result.Available { + a.notice = "" + } + } + a.render() case key := <-a.keyCh: a.handleKey(ctx, key) a.render() @@ -299,6 +343,20 @@ func (a *App) Run(ctx context.Context) error { return nil } +func (a *App) startUpdateCheck(ctx context.Context) { + if a.checkUpdate == nil { + return + } + a.update.Checking = true + go func() { + result, err := a.checkUpdate(ctx) + select { + case a.updateCh <- updateResult{result: result, err: err}: + case <-ctx.Done(): + } + }() +} + type refreshTicker interface { Reset(time.Duration) } @@ -360,7 +418,7 @@ func (a *App) fetchSnapshot(ctx context.Context, previous komari.Snapshot, fullF } func (a *App) fetchDetail(ctx context.Context, uuid string, force bool) { - if uuid == "" { + if uuid == "" || a.client == nil { return } key := detailKey{UUID: uuid, Window: a.window} diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 4df9912..e4bb840 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -57,6 +58,59 @@ func TestNewWithOptionsUsesTimeoutOptions(t *testing.T) { } } +func TestStartUpdateCheckRecordsAvailableUpdate(t *testing.T) { + app := NewWithOptions(nil, Options{ + CheckUpdate: func(ctx context.Context) (UpdateCheckResult, error) { + return UpdateCheckResult{LatestVersion: "v0.2.0", AssetName: "ktui_v0.2.0_linux_amd64.tar.gz", Available: true}, nil + }, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + app.startUpdateCheck(ctx) + result := <-app.updateCh + app.update.Checking = false + app.update.Checked = true + app.update.Err = result.err + app.update.Available = result.result.Available + app.update.Latest = result.result.LatestVersion + + if !app.update.Available || app.update.Latest != "v0.2.0" { + t.Fatalf("update = %+v, want available v0.2.0", app.update) + } +} + +func TestUpdateAvailableFooterAndHint(t *testing.T) { + app := NewWithOptions(nil, Options{}) + app.update.Available = true + app.update.Latest = "v0.2.0" + + if !strings.Contains(app.footerText(), "u update") { + t.Fatalf("footer = %q, want update action", app.footerText()) + } + + app.handleKey(context.Background(), keyEvent{name: "update-hint"}) + if !strings.Contains(app.notice, "ktui update") { + t.Fatalf("notice = %q, want update command", app.notice) + } +} + +func TestUpdateFooterClickShowsHint(t *testing.T) { + app := NewWithOptions(nil, Options{}) + app.update.Available = true + app.update.Latest = "v0.2.0" + _, height := terminalSize() + x, _, ok := footerLabelBounds(app.footerText(), "u update") + if !ok { + t.Fatal("missing u update footer label") + } + + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if !strings.Contains(app.notice, "v0.2.0") { + t.Fatalf("notice = %q, want update hint", app.notice) + } +} + func TestRealtimeRecordsMergeRecentAndLiveSamples(t *testing.T) { app := NewWithOptions(nil, Options{RefreshInterval: 2 * time.Second}) node := komari.Node{UUID: "node-1", Name: "node"} diff --git a/internal/tui/frame.go b/internal/tui/frame.go index ee2b8b7..3c90f7a 100644 --- a/internal/tui/frame.go +++ b/internal/tui/frame.go @@ -115,6 +115,12 @@ func (a *App) headerLines(width int) []string { context := strings.Join(contextParts, " ") if a.err != nil { context = a.style.red("ERROR " + a.err.Error()) + } else if a.notice != "" { + context = a.style.yellow(a.notice) + } else if a.update.Available { + context = a.style.yellow(fmt.Sprintf("UPDATE %s available run `ktui update`", valueOr(a.update.Latest, "latest"))) + } else if a.update.Checking { + context = a.style.dim("checking for updates...") } lines := []string{ @@ -148,13 +154,25 @@ func headerCompactUnit(value string) string { } func (a *App) footerLine(width int) string { - footer := " ↑↓/jk select Enter detail s settings m mode r refresh a ascii q quit " + return a.style.inverse(cleanLine(a.footerText(), width)) +} + +func (a *App) footerText() string { if a.settings { - footer = " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle " - } else if a.detail { - footer = " Esc/q back 1-5/h/l tabs [ ] window j/k scroll s settings r refresh " + return " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle " } - return a.style.inverse(cleanLine(footer, width)) + if a.detail { + footer := " Back(Esc/q) 1-5/h/l tabs [ ] window j/k scroll s settings r refresh " + if a.update.Available { + footer += " u update " + } + return footer + } + footer := " ↑↓/jk select Enter detail s settings m mode r refresh a ascii q quit " + if a.update.Available { + footer += " u update " + } + return footer } func (a *App) adjustScroll(visibleRows int) { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 5818e8b..fdd4982 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -4,10 +4,12 @@ import ( "context" "io" "os" + "strconv" ) func (a *App) readKeys(ctx context.Context) { buf := make([]byte, 8) + pending := make([]byte, 0, 32) for { select { case <-ctx.Done(): @@ -24,7 +26,10 @@ func (a *App) readKeys(ctx context.Context) { if n == 0 { continue } - for _, key := range parseKeys(buf[:n]) { + pending = append(pending, buf[:n]...) + keys, rest := parseKeysWithRemainder(pending) + pending = rest + for _, key := range keys { select { case a.keyCh <- key: default: @@ -34,6 +39,11 @@ func (a *App) readKeys(ctx context.Context) { } func parseKeys(data []byte) []keyEvent { + keys, _ := parseKeysWithRemainder(data) + return keys +} + +func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) { out := make([]keyEvent, 0, len(data)) for i := 0; i < len(data); i++ { switch data[i] { @@ -57,6 +67,8 @@ func parseKeys(data []byte) []keyEvent { out = append(out, keyEvent{name: "mode"}) case 's', 'S': out = append(out, keyEvent{name: "settings"}) + case 'u', 'U': + out = append(out, keyEvent{name: "update-hint"}) case 'j': out = append(out, keyEvent{name: "down"}) case 'k': @@ -79,6 +91,17 @@ func parseKeys(data []byte) []keyEvent { out = append(out, keyEvent{name: "window-right"}) case 0x1b: if i+2 < len(data) && data[i+1] == '[' { + if data[i+2] == '<' { + key, next, ok, incomplete := parseSGRMouse(data, i) + if incomplete { + return out, append([]byte(nil), data[i:]...) + } + if ok { + out = append(out, key) + i = next - 1 + } + continue + } switch data[i+2] { case 'A': out = append(out, keyEvent{name: "up"}) @@ -112,13 +135,74 @@ func parseKeys(data []byte) []keyEvent { } } } - return out + return out, nil +} + +func parseSGRMouse(data []byte, start int) (keyEvent, int, bool, bool) { + i := start + 3 + fieldStart := i + values := make([]int, 0, 3) + for ; i < len(data); i++ { + switch data[i] { + case ';': + value, ok := parseMouseNumber(data[fieldStart:i]) + if !ok { + return keyEvent{}, i + 1, false, false + } + values = append(values, value) + fieldStart = i + 1 + case 'M', 'm': + value, ok := parseMouseNumber(data[fieldStart:i]) + if !ok { + return keyEvent{}, i + 1, false, false + } + values = append(values, value) + if len(values) != 3 { + return keyEvent{}, i + 1, false, false + } + return mouseKey(values[0], values[1], values[2], data[i] == 'm'), i + 1, true, false + } + } + return keyEvent{}, len(data), false, true +} + +func parseMouseNumber(data []byte) (int, bool) { + if len(data) == 0 { + return 0, false + } + value, err := strconv.Atoi(string(data)) + if err != nil { + return 0, false + } + return value, true +} + +func mouseKey(button int, x int, y int, release bool) keyEvent { + switch button & 0b11_000000 { + case 64: + if button&1 == 0 { + return keyEvent{name: "mouse-wheel-up", x: x, y: y} + } + return keyEvent{name: "mouse-wheel-down", x: x, y: y} + } + if !release && button&0b11 == 0 { + return keyEvent{name: "mouse-left", x: x, y: y} + } + return keyEvent{name: "mouse-ignore", x: x, y: y} } func (a *App) handleKey(ctx context.Context, key keyEvent) { previous := a.selected previousTab := a.tab previousWindow := a.window + if isMouseKey(key.name) { + a.handleMouse(ctx, key) + a.clampSelection() + if a.scroll < 0 { + a.scroll = 0 + } + return + } if a.settings { a.handleSettingsKey(key) return @@ -139,10 +223,9 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { a.scroll = 0 } case "settings": - a.settingsWasDetail = a.detail - a.settings = true - a.detail = false - a.scroll = 0 + a.openSettings() + case "update-hint": + a.showUpdateHint() case "open": if len(a.snapshot.Nodes) > 0 { a.detail = true @@ -241,15 +324,21 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { } } +func isMouseKey(name string) bool { + switch name { + case "mouse-left", "mouse-wheel-up", "mouse-wheel-down", "mouse-ignore": + return true + default: + return false + } +} + func (a *App) handleSettingsKey(key keyEvent) { switch key.name { case "force-quit": a.quit = true case "quit", "back", "settings": - a.settings = false - a.detail = a.settingsWasDetail - a.settingsWasDetail = false - a.scroll = 0 + a.closeSettings() case "up": a.moveSettingsSelection(-1) case "down": diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go new file mode 100644 index 0000000..9e705f0 --- /dev/null +++ b/internal/tui/mouse.go @@ -0,0 +1,331 @@ +package tui + +import ( + "context" + "strings" +) + +const ( + mouseHeaderRows = 4 + sheetCardHeight = 9 + sheetCardGap = 2 + sheetMinWidth = 40 + lineHeaderRows = 3 + detailBackClickWidth = 14 +) + +func (a *App) handleMouse(ctx context.Context, key keyEvent) { + switch key.name { + case "mouse-wheel-up": + a.handleMouseWheel(ctx, -1) + case "mouse-wheel-down": + a.handleMouseWheel(ctx, 1) + case "mouse-left": + a.handleMouseClick(ctx, key.x, key.y) + } +} + +func (a *App) handleMouseWheel(ctx context.Context, delta int) { + previous := a.selected + previousTab := a.tab + previousWindow := a.window + if a.settings { + a.moveSettingsSelection(delta) + return + } + if a.detail { + a.scroll += delta * a.detailScrollStep() + if a.scroll < 0 { + a.scroll = 0 + } + if previous != a.selected || previousTab != a.tab || previousWindow != a.window || a.tabNeedsDetail() { + a.ensureSelectedDetail(ctx) + } + return + } + a.selected += delta + a.clampSelection() + if previous != a.selected && a.detail { + a.ensureSelectedDetail(ctx) + } +} + +func (a *App) handleMouseClick(ctx context.Context, x int, y int) { + if x <= 0 { + return + } + if a.clickFooter(ctx, x, y) { + return + } + if a.settings { + a.clickSettings(y) + return + } + if a.detail { + if a.clickDetailBack(x, y) { + return + } + if y <= mouseHeaderRows { + return + } + a.clickDetailChrome(ctx, x, y) + return + } + if y <= mouseHeaderRows { + return + } + if len(a.snapshot.Nodes) == 0 { + return + } + if a.mode == ModeLine { + a.clickLine(ctx, y) + return + } + a.clickSheet(ctx, x, y) +} + +func (a *App) clickFooter(ctx context.Context, x int, y int) bool { + _, height := terminalSize() + if y != height { + return false + } + if a.settings { + switch { + case footerHit(a.footerText(), "Esc/q back", x): + a.closeSettings() + case footerHit(a.footerText(), "←→/h/l adjust", x), footerHit(a.footerText(), "Enter toggle", x): + a.adjustSelectedSetting(1) + default: + return false + } + return true + } + if a.detail { + switch { + case footerHit(a.footerText(), "Back", x): + a.detail = false + a.scroll = 0 + case footerHit(a.footerText(), "s settings", x): + a.openSettings() + case footerHit(a.footerText(), "r refresh", x): + a.requestFullRefresh() + if len(a.snapshot.Nodes) > 0 { + a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) + } + case footerHit(a.footerText(), "u update", x): + a.showUpdateHint() + default: + return false + } + return true + } + switch { + case footerHit(a.footerText(), "Enter detail", x): + a.openSelectedDetail(ctx) + case footerHit(a.footerText(), "s settings", x): + a.openSettings() + case footerHit(a.footerText(), "m mode", x): + if a.mode == ModeLine { + a.mode = ModeSheet + } else { + a.mode = ModeLine + } + case footerHit(a.footerText(), "r refresh", x): + a.requestFullRefresh() + case footerHit(a.footerText(), "a ascii", x): + a.style.ASCII = !a.style.ASCII + case footerHit(a.footerText(), "q quit", x): + a.quit = true + case footerHit(a.footerText(), "u update", x): + a.showUpdateHint() + default: + return false + } + return true +} + +func (a *App) clickSettings(y int) { + bodyRow := y - mouseHeaderRows - 1 + if bodyRow < 0 { + return + } + itemRow := bodyRow - 1 + if a.settingsStatus != "" { + itemRow-- + } + if itemRow < 0 || itemRow >= a.settingsCount() { + return + } + a.settingsSelected = itemRow +} + +func (a *App) clickLine(ctx context.Context, y int) { + bodyRow := y - mouseHeaderRows - 1 + row := bodyRow - lineHeaderRows + if row < 0 { + return + } + index := a.scroll + row + if index >= 0 && index < len(a.snapshot.Nodes) { + a.selected = index + a.clampSelection() + a.openSelectedDetail(ctx) + } +} + +func (a *App) clickSheet(ctx context.Context, x int, y int) { + width, height := terminalSize() + drawWidth := width + if drawWidth > 1 { + drawWidth-- + } + bodyHeight := height - mouseHeaderRows - 1 + if bodyHeight < 1 { + bodyHeight = 1 + } + columns, cardWidth := sheetLayout(drawWidth) + bodyRow := y - mouseHeaderRows - 1 + if bodyRow < 0 { + return + } + row := bodyRow / sheetCardHeight + lineInCard := bodyRow % sheetCardHeight + if lineInCard >= sheetCardHeight { + return + } + x0 := x - 1 + columnSpan := cardWidth + sheetCardGap + col := x0 / columnSpan + if col < 0 || col >= columns { + return + } + if x0%columnSpan >= cardWidth { + return + } + rowsVisible := max(1, bodyHeight/sheetCardHeight) + if row >= rowsVisible { + return + } + index := (a.scroll+row)*columns + col + if index >= 0 && index < len(a.snapshot.Nodes) { + a.selected = index + a.clampSelection() + a.openSelectedDetail(ctx) + } +} + +func (a *App) openSelectedDetail(ctx context.Context) { + if len(a.snapshot.Nodes) == 0 { + return + } + a.detail = true + a.scroll = 0 + a.ensureSelectedDetail(ctx) +} + +func (a *App) clickDetailBack(x int, y int) bool { + _, height := terminalSize() + if y != height || x > detailBackClickWidth { + return false + } + a.detail = false + a.scroll = 0 + return true +} + +func (a *App) openSettings() { + a.settingsWasDetail = a.detail + a.settings = true + a.detail = false + a.scroll = 0 +} + +func (a *App) closeSettings() { + a.settings = false + a.detail = a.settingsWasDetail + a.settingsWasDetail = false + a.scroll = 0 +} + +func (a *App) showUpdateHint() { + if !a.update.Available { + return + } + latest := valueOr(a.update.Latest, "latest") + a.notice = "update available: " + latest + " quit and run `ktui update`" +} + +func footerHit(footer string, label string, x int) bool { + start, end, ok := footerLabelBounds(footer, label) + return ok && x >= start && x <= end +} + +func footerLabelBounds(footer string, label string) (int, int, bool) { + index := strings.Index(footer, label) + if index < 0 { + return 0, 0, false + } + start := displayWidth(footer[:index]) + 1 + end := start + displayWidth(label) - 1 + return start, end, true +} + +func (a *App) clickDetailChrome(ctx context.Context, x int, y int) { + bodyRow := y - mouseHeaderRows - 1 + switch bodyRow { + case 2: + if tab, ok := hitDetailTab(x); ok { + a.tab = tab + a.scroll = 0 + if a.tabNeedsDetail() { + a.ensureSelectedDetail(ctx) + } + } + case 3: + if window, ok := hitDetailWindow(x); ok { + a.window = window + a.scroll = 0 + a.ensureSelectedDetail(ctx) + } + } +} + +func sheetLayout(width int) (int, int) { + columns := max(1, (width+sheetCardGap)/(sheetMinWidth+sheetCardGap)) + for columns > 1 { + cardWidth := (width - sheetCardGap*(columns-1)) / columns + if cardWidth >= sheetMinWidth { + break + } + columns-- + } + cardWidth := width + if columns > 1 { + cardWidth = (width - sheetCardGap*(columns-1)) / columns + } + return columns, cardWidth +} + +func hitDetailTab(x int) (int, bool) { + pos := 1 + for i, name := range tabNames { + width := len(" ") + len("1 ") + len(name) + len(" ") + if x >= pos && x < pos+width { + return i, true + } + pos += width + 1 + } + return 0, false +} + +func hitDetailWindow(x int) (int, bool) { + pos := len(" window ") + 1 + for i, window := range detailWindows { + width := len(" ") + len(window.Label) + len(" ") + if x >= pos && x < pos+width { + return i, true + } + pos += width + 1 + } + return 0, false +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go new file mode 100644 index 0000000..a070f45 --- /dev/null +++ b/internal/tui/mouse_test.go @@ -0,0 +1,202 @@ +package tui + +import ( + "context" + "testing" + + "ktui/internal/komari" +) + +func TestParseSGRMouseWheelAndClick(t *testing.T) { + keys := parseKeys([]byte("\x1b[<64;10;5M\x1b[<65;10;6M\x1b[<0;12;7M\x1b[<0;12;7m")) + if len(keys) != 4 { + t.Fatalf("keys len = %d, want 4: %#v", len(keys), keys) + } + want := []string{"mouse-wheel-up", "mouse-wheel-down", "mouse-left", "mouse-ignore"} + for i := range want { + if keys[i].name != want[i] { + t.Fatalf("key %d = %s, want %s", i, keys[i].name, want[i]) + } + } + if keys[2].x != 12 || keys[2].y != 7 { + t.Fatalf("click position = %d,%d; want 12,7", keys[2].x, keys[2].y) + } +} + +func TestParseSGRMouseSplitSequence(t *testing.T) { + keys, rest := parseKeysWithRemainder([]byte("\x1b[<0;12")) + if len(keys) != 0 { + t.Fatalf("keys = %#v, want none", keys) + } + if string(rest) != "\x1b[<0;12" { + t.Fatalf("rest = %q", rest) + } + + keys, rest = parseKeysWithRemainder(append(rest, []byte(";7M")...)) + if len(rest) != 0 { + t.Fatalf("rest = %q, want empty", rest) + } + if len(keys) != 1 || keys[0].name != "mouse-left" || keys[0].x != 12 || keys[0].y != 7 { + t.Fatalf("keys = %#v, want mouse-left at 12,7", keys) + } +} + +func TestMouseClickLineOpensNodeDetail(t *testing.T) { + app := NewWithOptions(nil, Options{Mode: ModeLine}) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{ + {UUID: "n1", Name: "one"}, + {UUID: "n2", Name: "two"}, + {UUID: "n3", Name: "three"}, + }, + Status: map[string]komari.Status{}, + } + + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: 2, y: mouseHeaderRows + lineHeaderRows + 2}) + if app.selected != 1 { + t.Fatalf("selected = %d, want 1", app.selected) + } + if !app.detail { + t.Fatal("clicking a node should open detail view") + } + if app.scroll != 0 { + t.Fatalf("scroll = %d, want 0", app.scroll) + } +} + +func TestMouseClickDetailFooterBackReturnsToList(t *testing.T) { + app := NewWithOptions(nil, Options{}) + app.detail = true + app.scroll = 12 + _, height := terminalSize() + x, _, ok := footerLabelBounds(app.footerText(), "Back") + if !ok { + t.Fatal("missing Back footer label") + } + + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if app.detail { + t.Fatal("clicking detail footer back area should return to list") + } + if app.scroll != 0 { + t.Fatalf("scroll = %d, want 0", app.scroll) + } +} + +func TestMouseClickListFooterActions(t *testing.T) { + app := NewWithOptions(nil, Options{}) + _, height := terminalSize() + + clickFooterLabel := func(label string) { + t.Helper() + x, _, ok := footerLabelBounds(app.footerText(), label) + if !ok { + t.Fatalf("missing footer label %q in %q", label, app.footerText()) + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + } + + clickFooterLabel("s settings") + if !app.settings { + t.Fatal("clicking settings footer action should open settings") + } + app.closeSettings() + + clickFooterLabel("m mode") + if app.mode != ModeLine { + t.Fatalf("mode = %s, want line", app.mode) + } + + clickFooterLabel("r refresh") + select { + case <-app.refreshCh: + default: + t.Fatal("clicking refresh footer action should request refresh") + } + + clickFooterLabel("a ascii") + if !app.style.ASCII { + t.Fatal("clicking ascii footer action should toggle ASCII mode") + } + + clickFooterLabel("q quit") + if !app.quit { + t.Fatal("clicking quit footer action should set quit") + } +} + +func TestMouseClickDetailFooterSettingsAndRefresh(t *testing.T) { + app := NewWithOptions(nil, Options{}) + app.detail = true + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{{UUID: "n1", Name: "one"}}, + Status: map[string]komari.Status{}, + } + _, height := terminalSize() + + x, _, ok := footerLabelBounds(app.footerText(), "r refresh") + if !ok { + t.Fatal("missing refresh footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + select { + case <-app.refreshCh: + default: + t.Fatal("clicking detail refresh footer action should request refresh") + } + + x, _, ok = footerLabelBounds(app.footerText(), "s settings") + if !ok { + t.Fatal("missing settings footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if !app.settings || app.detail || !app.settingsWasDetail { + t.Fatalf("settings=%t detail=%t settingsWasDetail=%t, want settings opened from detail", app.settings, app.detail, app.settingsWasDetail) + } +} + +func TestMouseClickSettingsFooterActions(t *testing.T) { + app := NewWithOptions(nil, Options{Mode: ModeSheet}) + app.settings = true + selectSetting(t, app, "mode") + _, height := terminalSize() + + x, _, ok := footerLabelBounds(app.footerText(), "Enter toggle") + if !ok { + t.Fatal("missing Enter toggle footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if app.mode != ModeLine { + t.Fatalf("mode = %s, want line after settings footer toggle", app.mode) + } + + x, _, ok = footerLabelBounds(app.footerText(), "Esc/q back") + if !ok { + t.Fatal("missing back footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if app.settings { + t.Fatal("clicking settings back footer action should close settings") + } +} + +func TestMouseWheelSettingsSelection(t *testing.T) { + app := NewWithOptions(nil, Options{}) + app.settings = true + + app.handleKey(context.Background(), keyEvent{name: "mouse-wheel-down", x: 1, y: 1}) + if app.settingsSelected != 1 { + t.Fatalf("settingsSelected = %d, want 1", app.settingsSelected) + } + app.handleKey(context.Background(), keyEvent{name: "mouse-wheel-up", x: 1, y: 1}) + if app.settingsSelected != 0 { + t.Fatalf("settingsSelected = %d, want 0", app.settingsSelected) + } +} + +func TestSheetLayoutMinimumWidth(t *testing.T) { + columns, cardWidth := sheetLayout(100) + if columns != 2 || cardWidth != 49 { + t.Fatalf("layout = %d columns, width %d; want 2,49", columns, cardWidth) + } +} diff --git a/internal/tui/sheet.go b/internal/tui/sheet.go index 7845583..58a3248 100644 --- a/internal/tui/sheet.go +++ b/internal/tui/sheet.go @@ -19,21 +19,8 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight) } - cardHeight := 9 - gap := 2 - minCardWidth := 40 - columns := max(1, (width+gap)/(minCardWidth+gap)) - for columns > 1 { - cardWidth := (width - gap*(columns-1)) / columns - if cardWidth >= minCardWidth { - break - } - columns-- - } - cardWidth := width - if columns > 1 { - cardWidth = (width - gap*(columns-1)) / columns - } + cardHeight := sheetCardHeight + columns, cardWidth := sheetLayout(width) rowsVisible := max(1, bodyHeight/cardHeight) selectedRow := a.selected / columns @@ -68,7 +55,7 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { var line strings.Builder for col, card := range rowCards { if col > 0 { - line.WriteString(strings.Repeat(" ", gap)) + line.WriteString(strings.Repeat(" ", sheetCardGap)) } line.WriteString(card[lineIndex]) } diff --git a/internal/tui/term_unix.go b/internal/tui/term_unix.go index 235fcd6..96c73cd 100644 --- a/internal/tui/term_unix.go +++ b/internal/tui/term_unix.go @@ -30,7 +30,7 @@ func enterRawMode() (*terminalState, error) { if err := ioctlSetTermios(fd, raw); err != nil { return nil, err } - fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l") + fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h") return &terminalState{termios: old}, nil } @@ -39,7 +39,7 @@ func (s *terminalState) restore() { return } _ = ioctlSetTermios(int(os.Stdin.Fd()), s.termios) - fmt.Print("\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m") + fmt.Print("\x1b[?1006l\x1b[?1000l\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m") } func installSignalRestore(state *terminalState) func() { diff --git a/internal/tui/term_windows.go b/internal/tui/term_windows.go index 136714b..d4ad788 100644 --- a/internal/tui/term_windows.go +++ b/internal/tui/term_windows.go @@ -59,7 +59,7 @@ func enterRawMode() (*terminalState, error) { return nil, err } - fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l") + fmt.Print("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1000h\x1b[?1006h") return &terminalState{ input: input, output: output, @@ -74,7 +74,7 @@ func (s *terminalState) restore() { } _ = setConsoleMode(s.input, s.inputMode) _ = setConsoleMode(s.output, s.outputMode) - fmt.Print("\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m") + fmt.Print("\x1b[?1006l\x1b[?1000l\x1b[?7h\x1b[?25h\x1b[?1049l\x1b[0m") } func installSignalRestore(state *terminalState) func() { diff --git a/internal/update/update.go b/internal/update/update.go index 4c0ab05..aa887af 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -33,6 +33,13 @@ type Options struct { Stdout io.Writer } +type CheckResult struct { + CurrentVersion string + LatestVersion string + AssetName string + Available bool +} + type release struct { TagName string `json:"tag_name"` Name string `json:"name"` @@ -120,6 +127,42 @@ func Run(ctx context.Context, opts Options) error { return nil } +func Check(ctx context.Context, opts Options) (CheckResult, error) { + if opts.APIBaseURL == "" { + opts.APIBaseURL = DefaultAPIBaseURL + } + if opts.Timeout <= 0 { + opts.Timeout = 10 * time.Second + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + client := &http.Client{Timeout: opts.Timeout} + rel, err := fetchRelease(ctx, client, opts.APIBaseURL, opts.TargetVersion) + if err != nil { + return CheckResult{}, err + } + if rel.TagName == "" { + return CheckResult{}, errors.New("release response does not include a tag name") + } + + result := CheckResult{ + CurrentVersion: opts.CurrentVersion, + LatestVersion: displayVersion(rel.TagName), + } + if sameVersion(opts.CurrentVersion, rel.TagName) { + return result, nil + } + archiveAsset, err := selectArchiveAsset(rel.Assets, runtime.GOOS, runtime.GOARCH) + if err != nil { + return CheckResult{}, err + } + result.AssetName = archiveAsset.Name + result.Available = true + return result, nil +} + func fetchRelease(ctx context.Context, client *http.Client, apiBaseURL, tag string) (release, error) { apiBaseURL = strings.TrimRight(apiBaseURL, "/") endpoint := apiBaseURL + "/releases/latest" diff --git a/internal/update/update_test.go b/internal/update/update_test.go index cd37b3e..4258cde 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -1,6 +1,13 @@ package update -import "testing" +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" +) func TestSelectArchiveAsset(t *testing.T) { assets := []asset{ @@ -47,3 +54,62 @@ func TestSameVersion(t *testing.T) { t.Fatal("dev version should never be considered up to date") } } + +func TestCheckReportsAvailableUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/releases/latest" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(release{ + TagName: "v0.2.0", + Assets: []asset{ + {Name: "ktui_v0.2.0_" + runtime.GOOS + "_" + runtime.GOARCH + archiveExt(), BrowserDownloadURL: serverURL(r, "/download")}, + }, + }) + })) + defer server.Close() + + result, err := Check(context.Background(), Options{ + APIBaseURL: server.URL + "/api", + CurrentVersion: "v0.1.0", + }) + if err != nil { + t.Fatal(err) + } + if !result.Available || result.LatestVersion != "v0.2.0" || result.AssetName == "" { + t.Fatalf("result = %+v, want available v0.2.0", result) + } +} + +func TestCheckIgnoresSameVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(release{TagName: "v0.1.0"}) + })) + defer server.Close() + + result, err := Check(context.Background(), Options{ + APIBaseURL: server.URL, + CurrentVersion: "0.1.0", + }) + if err != nil { + t.Fatal(err) + } + if result.Available { + t.Fatalf("result = %+v, want no update", result) + } +} + +func archiveExt() string { + if runtime.GOOS == "windows" { + return ".zip" + } + return ".tar.gz" +} + +func serverURL(r *http.Request, path string) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + r.Host + path +}