feat: add mouse support and update install flow
This commit is contained in:
1 parent
b8e444bd0a
commit
71855bf4e7
16 files changed
+1214
-39
No files matched your search
@@ -36,6 +36,8 @@ archives:
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
- install.sh
|
||||
- install.ps1
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
@@ -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`:在列表页退出
|
||||
|
||||
## 版本信息
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
|
||||
+110
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+24
-6
@@ -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 "
|
||||
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 a.style.inverse(cleanLine(a.footerText(), width))
|
||||
}
|
||||
return a.style.inverse(cleanLine(footer, width))
|
||||
|
||||
func (a *App) footerText() string {
|
||||
if a.settings {
|
||||
return " Esc/q back ↑↓/jk select ←→/h/l adjust Enter toggle "
|
||||
}
|
||||
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) {
|
||||
|
||||
+99
-10
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user