feat: add mouse support and update install flow

This commit is contained in:
gary committed 2026-07-02 18:22:27 +08:00
1 parent b8e444bd0a
commit 71855bf4e7
16 files changed
+1213 -38

No files matched your search

+2
View File
@@ -36,6 +36,8 @@ archives:
files: files:
- README.md - README.md
- LICENSE - LICENSE
- install.sh
- install.ps1
checksum: checksum:
name_template: checksums.txt name_template: checksums.txt
+44 -1
View File
@@ -24,9 +24,43 @@ https://www.nodeseek.com/post-710243-1
- 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情
- 支持配置文件,默认持久化到系统用户配置目录 - 支持配置文件,默认持久化到系统用户配置目录
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动 - 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
- 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端 - 支持 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 或更新版本。 需要 Go 1.26 或更新版本。
@@ -65,6 +99,12 @@ go run ./cmd/ktui
./ktui update ./ktui update
``` ```
进入 TUI 时会自动在后台检查是否有新版本。发现新版本后,界面顶部会提示 `UPDATE ... available`,底部会出现 `u update`;按 `u` 或点击该提示会显示更新命令。实际更新请退出 TUI 后运行:
```sh
ktui update
```
指定 Komari 地址: 指定 Komari 地址:
```sh ```sh
@@ -208,6 +248,8 @@ EXP -
- `↑` / `k`:列表页选择上一个节点;详情页向上滚动一张卡片 - `↑` / `k`:列表页选择上一个节点;详情页向上滚动一张卡片
- `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片 - `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片
- 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项
- 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回
- `PgUp` / `PgDn`:快速滚动列表或详情页 - `PgUp` / `PgDn`:快速滚动列表或详情页
- `Enter` / `o`:打开选中节点的详情页 - `Enter` / `o`:打开选中节点的详情页
- `Esc` / `b` / `q`:从详情页返回列表页 - `Esc` / `b` / `q`:从详情页返回列表页
@@ -217,6 +259,7 @@ EXP -
- `r`:立即刷新 - `r`:立即刷新
- `d`:打开或重新加载选中节点的详情数据 - `d`:打开或重新加载选中节点的详情数据
- `a`:切换 ASCII 兼容模式 - `a`:切换 ASCII 兼容模式
- `u`:有新版本时显示更新命令
- `q` / `Ctrl-C`:在列表页退出 - `q` / `Ctrl-C`:在列表页退出
## 版本信息 ## 版本信息
+28
View File
@@ -158,6 +158,7 @@ func main() {
RealtimePoints: realtimePoints, RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis, ChartYAxisMode: chartYAxis,
SaveSettings: saveTUISettings, SaveSettings: saveTUISettings,
CheckUpdate: checkSoftwareUpdate,
ASCII: ascii, ASCII: ascii,
NoColor: noColor, NoColor: noColor,
Mode: mode, Mode: mode,
@@ -219,6 +220,22 @@ func saveTUISettings(settings tui.PersistentSettings) error {
return err 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 { func handleConfig(args []string) error {
if len(args) == 0 { if len(args) == 0 {
printConfigHelp() printConfigHelp()
@@ -453,6 +470,9 @@ func printKeysHelp() {
List layer: List layer:
Up/k, Down/j select server 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 PgUp, PgDn jump faster
Enter/o open selected server detail Enter/o open selected server detail
s open settings s open settings
@@ -460,19 +480,27 @@ List layer:
r refresh now r refresh now
d open or reload selected server detail data d open or reload selected server detail data
a toggle ASCII compatibility mode a toggle ASCII compatibility mode
u show update command when an update is available
q, Ctrl-C quit q, Ctrl-C quit
Detail layer: Detail layer:
Esc, b, q return to list layer Esc, b, q return to list layer
Mouse click Back return to list layer
h/l, 1-5, Tab switch detail tabs h/l, 1-5, Tab switch detail tabs
[, ] switch time window [, ] switch time window
Up/k, Down/j scroll one card 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 s open settings
u show update command when an update is available
PgUp, PgDn scroll faster PgUp, PgDn scroll faster
Settings layer: Settings layer:
Esc, q, s return to previous layer Esc, q, s return to previous layer
Up/k, Down/j select setting Up/k, Down/j select setting
Mouse wheel/click select setting
Footer click back/adjust/toggle
Left/h, Right/l adjust value Left/h, Right/l adjust value
Enter toggle or advance value Enter toggle or advance value
`) `)
+110
View File
@@ -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
}
Executable
+144
View File
@@ -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
+59 -1
View File
@@ -16,6 +16,7 @@ type App struct {
detailCacheTTL time.Duration detailCacheTTL time.Duration
realtimePoints int realtimePoints int
saveSettings func(PersistentSettings) error saveSettings func(PersistentSettings) error
checkUpdate func(context.Context) (UpdateCheckResult, error)
style Style style Style
mode Mode mode Mode
@@ -32,11 +33,13 @@ type App struct {
settingsURL string settingsURL string
settingsAPIKey string settingsAPIKey string
chartYAxisMode chartYAxisMode chartYAxisMode chartYAxisMode
notice string
snapshot komari.Snapshot snapshot komari.Snapshot
err error err error
loading bool loading bool
fetching bool fetching bool
update updateState
// At most one refresh is queued while an in-flight request is finishing. // At most one refresh is queued while an in-flight request is finishing.
refreshPending bool refreshPending bool
intervalChanged bool intervalChanged bool
@@ -53,6 +56,7 @@ type App struct {
refreshCh chan struct{} refreshCh chan struct{}
resultCh chan fetchResult resultCh chan fetchResult
detailCh chan detailResult detailCh chan detailResult
updateCh chan updateResult
keyCh chan keyEvent keyCh chan keyEvent
} }
@@ -66,11 +70,19 @@ type Options struct {
RealtimePoints int RealtimePoints int
ChartYAxisMode string ChartYAxisMode string
SaveSettings func(PersistentSettings) error SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error)
ASCII bool ASCII bool
NoColor bool NoColor bool
Mode Mode Mode Mode
} }
type UpdateCheckResult struct {
CurrentVersion string
LatestVersion string
AssetName string
Available bool
}
type PersistentSettings struct { type PersistentSettings struct {
Interval string Interval string
Timeout string Timeout string
@@ -117,6 +129,20 @@ type detailResult struct {
detail nodeDetail 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 { type detailKey struct {
UUID string UUID string
Window int Window int
@@ -124,6 +150,8 @@ type detailKey struct {
type keyEvent struct { type keyEvent struct {
name string name string
x int
y int
} }
var tabNames = []string{"overview", "node", "history", "ping", "meta"} var tabNames = []string{"overview", "node", "history", "ping", "meta"}
@@ -209,6 +237,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
detailTimeout: opts.DetailTimeout, detailTimeout: opts.DetailTimeout,
detailCacheTTL: opts.DetailCacheTTL, detailCacheTTL: opts.DetailCacheTTL,
realtimePoints: opts.RealtimePoints, realtimePoints: opts.RealtimePoints,
checkUpdate: opts.CheckUpdate,
settingsURL: opts.URL, settingsURL: opts.URL,
settingsAPIKey: opts.APIKey, settingsAPIKey: opts.APIKey,
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor}, 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), refreshCh: make(chan struct{}, 2),
resultCh: make(chan fetchResult, 2), resultCh: make(chan fetchResult, 2),
detailCh: make(chan detailResult, 4), detailCh: make(chan detailResult, 4),
updateCh: make(chan updateResult, 1),
keyCh: make(chan keyEvent, 16), keyCh: make(chan keyEvent, 16),
loading: true, loading: true,
nodeDetail: map[detailKey]nodeDetail{}, nodeDetail: map[detailKey]nodeDetail{},
@@ -238,6 +268,7 @@ func (a *App) Run(ctx context.Context) error {
defer stopResize() defer stopResize()
go a.readKeys(ctx) go a.readKeys(ctx)
a.startUpdateCheck(ctx)
a.requestRefresh() a.requestRefresh()
ticker := time.NewTicker(a.refreshInterval) ticker := time.NewTicker(a.refreshInterval)
@@ -289,6 +320,19 @@ func (a *App) Run(ctx context.Context) error {
case detail := <-a.detailCh: case detail := <-a.detailCh:
a.nodeDetail[detail.key] = detail.detail a.nodeDetail[detail.key] = detail.detail
a.render() 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: case key := <-a.keyCh:
a.handleKey(ctx, key) a.handleKey(ctx, key)
a.render() a.render()
@@ -299,6 +343,20 @@ func (a *App) Run(ctx context.Context) error {
return nil 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 { type refreshTicker interface {
Reset(time.Duration) 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) { func (a *App) fetchDetail(ctx context.Context, uuid string, force bool) {
if uuid == "" { if uuid == "" || a.client == nil {
return return
} }
key := detailKey{UUID: uuid, Window: a.window} key := detailKey{UUID: uuid, Window: a.window}
+54
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "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) { func TestRealtimeRecordsMergeRecentAndLiveSamples(t *testing.T) {
app := NewWithOptions(nil, Options{RefreshInterval: 2 * time.Second}) app := NewWithOptions(nil, Options{RefreshInterval: 2 * time.Second})
node := komari.Node{UUID: "node-1", Name: "node"} node := komari.Node{UUID: "node-1", Name: "node"}
+23 -5
View File
@@ -115,6 +115,12 @@ func (a *App) headerLines(width int) []string {
context := strings.Join(contextParts, " ") context := strings.Join(contextParts, " ")
if a.err != nil { if a.err != nil {
context = a.style.red("ERROR " + a.err.Error()) 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{ lines := []string{
@@ -148,13 +154,25 @@ func headerCompactUnit(value string) string {
} }
func (a *App) footerLine(width int) 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 { if a.settings {
footer = " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle " return " 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 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) { func (a *App) adjustScroll(visibleRows int) {
+99 -10
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"io" "io"
"os" "os"
"strconv"
) )
func (a *App) readKeys(ctx context.Context) { func (a *App) readKeys(ctx context.Context) {
buf := make([]byte, 8) buf := make([]byte, 8)
pending := make([]byte, 0, 32)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -24,7 +26,10 @@ func (a *App) readKeys(ctx context.Context) {
if n == 0 { if n == 0 {
continue continue
} }
for _, key := range parseKeys(buf[:n]) { pending = append(pending, buf[:n]...)
keys, rest := parseKeysWithRemainder(pending)
pending = rest
for _, key := range keys {
select { select {
case a.keyCh <- key: case a.keyCh <- key:
default: default:
@@ -34,6 +39,11 @@ func (a *App) readKeys(ctx context.Context) {
} }
func parseKeys(data []byte) []keyEvent { func parseKeys(data []byte) []keyEvent {
keys, _ := parseKeysWithRemainder(data)
return keys
}
func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
out := make([]keyEvent, 0, len(data)) out := make([]keyEvent, 0, len(data))
for i := 0; i < len(data); i++ { for i := 0; i < len(data); i++ {
switch data[i] { switch data[i] {
@@ -57,6 +67,8 @@ func parseKeys(data []byte) []keyEvent {
out = append(out, keyEvent{name: "mode"}) out = append(out, keyEvent{name: "mode"})
case 's', 'S': case 's', 'S':
out = append(out, keyEvent{name: "settings"}) out = append(out, keyEvent{name: "settings"})
case 'u', 'U':
out = append(out, keyEvent{name: "update-hint"})
case 'j': case 'j':
out = append(out, keyEvent{name: "down"}) out = append(out, keyEvent{name: "down"})
case 'k': case 'k':
@@ -79,6 +91,17 @@ func parseKeys(data []byte) []keyEvent {
out = append(out, keyEvent{name: "window-right"}) out = append(out, keyEvent{name: "window-right"})
case 0x1b: case 0x1b:
if i+2 < len(data) && data[i+1] == '[' { 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] { switch data[i+2] {
case 'A': case 'A':
out = append(out, keyEvent{name: "up"}) 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) { func (a *App) handleKey(ctx context.Context, key keyEvent) {
previous := a.selected previous := a.selected
previousTab := a.tab previousTab := a.tab
previousWindow := a.window previousWindow := a.window
if isMouseKey(key.name) {
a.handleMouse(ctx, key)
a.clampSelection()
if a.scroll < 0 {
a.scroll = 0
}
return
}
if a.settings { if a.settings {
a.handleSettingsKey(key) a.handleSettingsKey(key)
return return
@@ -139,10 +223,9 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
a.scroll = 0 a.scroll = 0
} }
case "settings": case "settings":
a.settingsWasDetail = a.detail a.openSettings()
a.settings = true case "update-hint":
a.detail = false a.showUpdateHint()
a.scroll = 0
case "open": case "open":
if len(a.snapshot.Nodes) > 0 { if len(a.snapshot.Nodes) > 0 {
a.detail = true 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) { func (a *App) handleSettingsKey(key keyEvent) {
switch key.name { switch key.name {
case "force-quit": case "force-quit":
a.quit = true a.quit = true
case "quit", "back", "settings": case "quit", "back", "settings":
a.settings = false a.closeSettings()
a.detail = a.settingsWasDetail
a.settingsWasDetail = false
a.scroll = 0
case "up": case "up":
a.moveSettingsSelection(-1) a.moveSettingsSelection(-1)
case "down": case "down":
+331
View File
@@ -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
}
+202
View File
@@ -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)
}
}
+3 -16
View File
@@ -19,21 +19,8 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight) return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight)
} }
cardHeight := 9 cardHeight := sheetCardHeight
gap := 2 columns, cardWidth := sheetLayout(width)
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
}
rowsVisible := max(1, bodyHeight/cardHeight) rowsVisible := max(1, bodyHeight/cardHeight)
selectedRow := a.selected / columns selectedRow := a.selected / columns
@@ -68,7 +55,7 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
var line strings.Builder var line strings.Builder
for col, card := range rowCards { for col, card := range rowCards {
if col > 0 { if col > 0 {
line.WriteString(strings.Repeat(" ", gap)) line.WriteString(strings.Repeat(" ", sheetCardGap))
} }
line.WriteString(card[lineIndex]) line.WriteString(card[lineIndex])
} }
+2 -2
View File
@@ -30,7 +30,7 @@ func enterRawMode() (*terminalState, error) {
if err := ioctlSetTermios(fd, raw); err != nil { if err := ioctlSetTermios(fd, raw); err != nil {
return nil, err 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 return &terminalState{termios: old}, nil
} }
@@ -39,7 +39,7 @@ func (s *terminalState) restore() {
return return
} }
_ = ioctlSetTermios(int(os.Stdin.Fd()), s.termios) _ = 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() { func installSignalRestore(state *terminalState) func() {
+2 -2
View File
@@ -59,7 +59,7 @@ func enterRawMode() (*terminalState, error) {
return nil, err 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{ return &terminalState{
input: input, input: input,
output: output, output: output,
@@ -74,7 +74,7 @@ func (s *terminalState) restore() {
} }
_ = setConsoleMode(s.input, s.inputMode) _ = setConsoleMode(s.input, s.inputMode)
_ = setConsoleMode(s.output, s.outputMode) _ = 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() { func installSignalRestore(state *terminalState) func() {
+43
View File
@@ -33,6 +33,13 @@ type Options struct {
Stdout io.Writer Stdout io.Writer
} }
type CheckResult struct {
CurrentVersion string
LatestVersion string
AssetName string
Available bool
}
type release struct { type release struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
Name string `json:"name"` Name string `json:"name"`
@@ -120,6 +127,42 @@ func Run(ctx context.Context, opts Options) error {
return nil 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) { func fetchRelease(ctx context.Context, client *http.Client, apiBaseURL, tag string) (release, error) {
apiBaseURL = strings.TrimRight(apiBaseURL, "/") apiBaseURL = strings.TrimRight(apiBaseURL, "/")
endpoint := apiBaseURL + "/releases/latest" endpoint := apiBaseURL + "/releases/latest"
+67 -1
View File
@@ -1,6 +1,13 @@
package update package update
import "testing" import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"testing"
)
func TestSelectArchiveAsset(t *testing.T) { func TestSelectArchiveAsset(t *testing.T) {
assets := []asset{ assets := []asset{
@@ -47,3 +54,62 @@ func TestSameVersion(t *testing.T) {
t.Fatal("dev version should never be considered up to date") 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
}