layout: ikuti mock SVG — kolom tengah dashboard, toolbar workspace, kolom Aksi Tab
CI / test (push) Failing after 38s
CI / test (push) Failing after 38s
- Beranda: kolom tengah CTA 'masuk workspace' (klikable), strip Disclaimer dan Info aplikasi (versi/repo/config); geometri satu sumber (homeGeom). - Workspace: baris toolbar — pill Cari (klik atau '/' → cari-lompat daftar) + tombol [+ stack]/[konversi] klikable (sementara keduanya jalur konverter). - Detail kontainer: kolom kanan 'Aksi Tab' (mock TAB ACTION) berisi aksi tab aktif yang bisa diklik; disembunyikan di layar sempit agar tab bar utuh. - Footer: hint aksi diprioritaskan; status diagnostik dibuang bila tak muat. - internal/version: versi build dipakai CLI & strip Info aplikasi. - audio: Play/Status aman untuk Engine nil (Model zero-value di tes). - Tes: 3 skenario E2E baru (CTA, cari toolbar, kolom aksi) + tes layout.
This commit is contained in:
1 parent
8880660ce4
commit
5450a89a80
9 files changed
+638
-112
No files matched your search
+2
-35
@@ -3,14 +3,13 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/app"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/cli"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
|
||||
)
|
||||
|
||||
func usageText() string {
|
||||
@@ -33,7 +32,7 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "version", "--version":
|
||||
fmt.Println(versionString())
|
||||
fmt.Println(version.String())
|
||||
return
|
||||
case "doctor":
|
||||
os.Exit(cli.Doctor(os.Stdout))
|
||||
@@ -50,35 +49,3 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// versionString memakai build info VCS — tanpa ldflags.
|
||||
func versionString() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "tia dev"
|
||||
}
|
||||
ver := info.Main.Version
|
||||
if ver == "" || ver == "(devel)" || strings.HasPrefix(ver, "v0.0.0-") {
|
||||
ver = "dev"
|
||||
}
|
||||
rev, dirty := "", false
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if len(s.Value) >= 7 {
|
||||
rev = s.Value[:7]
|
||||
}
|
||||
case "vcs.modified":
|
||||
dirty = s.Value == "true"
|
||||
}
|
||||
}
|
||||
out := "tia " + ver
|
||||
if rev != "" {
|
||||
out += " (" + rev
|
||||
if dirty {
|
||||
out += "-dirty"
|
||||
}
|
||||
out += ")"
|
||||
}
|
||||
return out
|
||||
}
|
||||
+113
-2
@@ -599,6 +599,116 @@ func TestMouseHomeContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeCtaWorkspace — klik kotak CTA "masuk workspace" di kolom tengah
|
||||
// beranda → pindah ke workspace bagian Stack.
|
||||
func TestHomeCtaWorkspace(t *testing.T) {
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
u.waitContains("masuk workspace", 5*time.Second)
|
||||
if !u.clickText("masuk workspace") {
|
||||
t.Fatal("kotak CTA tidak ditemukan di beranda")
|
||||
}
|
||||
u.waitContains("[u] jalankan", 3*time.Second) // footer Stack = bukti pindah view
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolbarSearch — pill "Cari" di toolbar workspace: klik, ketik → kursor
|
||||
// melompat ke kontainer yang cocok; Enter keluar mode cari (paritas "/").
|
||||
func TestToolbarSearch(t *testing.T) {
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||
t.Skip("docker socket tidak ada")
|
||||
}
|
||||
cl, err := docker.New()
|
||||
if err != nil {
|
||||
t.Skipf("docker: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
items, err := cl.ListContainers(ctx)
|
||||
if err != nil || len(items) < 2 {
|
||||
t.Skipf("butuh >= 2 kontainer (err=%v, n=%d)", err, len(items))
|
||||
}
|
||||
|
||||
// Target: prefix nama yang tidak termuat di nama kontainer sebelumnya —
|
||||
// pencarian melompat ke kecocokan pertama dari atas.
|
||||
target := ""
|
||||
for i, it := range items {
|
||||
q := it.Name
|
||||
if len(q) > 6 {
|
||||
q = q[:6]
|
||||
}
|
||||
unique := true
|
||||
for k := 0; k < i; k++ {
|
||||
if strings.Contains(strings.ToLower(items[k].Name), strings.ToLower(q)) {
|
||||
unique = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if unique {
|
||||
target = q
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
t.Skip("nama kontainer tidak unik untuk uji cari")
|
||||
}
|
||||
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
u.send("2")
|
||||
u.waitContains("[1-5]", 3*time.Second)
|
||||
if !u.clickText("Cari") {
|
||||
t.Fatal("pill Cari tidak ditemukan")
|
||||
}
|
||||
u.send(target)
|
||||
u.waitContains("[ / "+target, 3*time.Second) // query masuk ke pill
|
||||
u.waitHighlighted(target, 3*time.Second) // kursor melompat ke item cocok
|
||||
u.send("\r") // Enter: keluar mode cari
|
||||
u.waitContains("[ / Cari ]", 3*time.Second)
|
||||
u.send("q") // bukti sudah keluar mode cari: q keluar, bukan mengetik
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabActionColumn — kolom kanan "Aksi Tab" (mock TAB ACTION) di detail
|
||||
// kontainer: item tampil per tab dan bisa diklik (klik [S] → toggle timestamp).
|
||||
func TestTabActionColumn(t *testing.T) {
|
||||
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||
t.Skip("docker socket tidak ada")
|
||||
}
|
||||
cl, err := docker.New()
|
||||
if err != nil {
|
||||
t.Skipf("docker: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
items, err := cl.ListContainers(ctx)
|
||||
if err != nil || len(items) == 0 {
|
||||
t.Skipf("tidak ada kontainer (err=%v)", err)
|
||||
}
|
||||
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
u.send("2")
|
||||
u.waitContains("[1-5]", 3*time.Second)
|
||||
u.waitContains("Aksi Tab", 3*time.Second)
|
||||
u.waitContains("ikut ekor", 3*time.Second)
|
||||
if !u.clickText("[S]") {
|
||||
t.Fatal("aksi [S] tidak ditemukan di kolom aksi tab")
|
||||
}
|
||||
u.waitContains("Timestamp log", 3*time.Second) // toast = aksi benar-benar jalan
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCommand — CLI `tia-love convert`: hasil di stdout, exit code jelas.
|
||||
func TestConvertCommand(t *testing.T) {
|
||||
out, err := exec.Command(binPath, "convert", "docker run -p 80:80 --name web nginx").CombinedOutput()
|
||||
@@ -804,8 +914,9 @@ func TestClickSelectsContainer(t *testing.T) {
|
||||
u.waitContains(short(items[0].Name), 5*time.Second)
|
||||
u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0)
|
||||
|
||||
// klik baris item ke-2: xterm y=4 (1-based) → app row 3 → indeks 1
|
||||
u.send("\x1b[<0;10;4M\x1b[<0;10;4m")
|
||||
// klik baris item ke-2: xterm y=5 (1-based) → app row 4 → indeks 1
|
||||
// (header + toolbar + border panel di atasnya).
|
||||
u.send("\x1b[<0;10;5M\x1b[<0;10;5m")
|
||||
u.waitHighlighted(short(items[1].Name), 3*time.Second)
|
||||
|
||||
u.send("q")
|
||||
|
||||
+232
-57
@@ -103,6 +103,9 @@ type Model struct {
|
||||
focus focusKind
|
||||
tab int
|
||||
|
||||
searching bool // mode cari daftar (pill toolbar): ketik → lompat ke item
|
||||
query string // isi pencarian; kosong = tidak menyaring
|
||||
|
||||
tailLines int
|
||||
showTimestamps bool
|
||||
|
||||
@@ -533,6 +536,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.modal != nil {
|
||||
return m, m.modalKeyMsg(msg) // modal menangkap semua tombol
|
||||
}
|
||||
if m.searching {
|
||||
return m, m.searchKeyMsg(msg) // mode cari menangkap semua teks
|
||||
}
|
||||
return m, m.handleKey(msg.String())
|
||||
|
||||
case tea.PasteMsg:
|
||||
@@ -547,8 +553,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.toast.Active() {
|
||||
m.toast = components.NewToast() // klik = tutup toast
|
||||
}
|
||||
// Elemen UI eksplisit dulu (F-022): hint footer, tab bar, panel beranda.
|
||||
if cmd := m.mouseUI(msg.X, msg.Y); cmd != nil {
|
||||
// Elemen UI eksplisit dulu (F-022): hint footer, toolbar, tab bar,
|
||||
// kolom aksi tab, panel beranda.
|
||||
if cmd, handled := m.mouseUI(msg.X, msg.Y); handled {
|
||||
return m, cmd
|
||||
}
|
||||
m.sound.Play(audio.Click)
|
||||
@@ -792,10 +799,18 @@ func (m Model) toastRows() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) headerRows() int { return 1 + m.toastRows() }
|
||||
// toolRows — baris toolbar workspace (1 di view daftar, 0 di beranda).
|
||||
func (m Model) toolRows() int {
|
||||
if m.view == viewList {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) headerRows() int { return 1 + m.toolRows() + m.toastRows() }
|
||||
|
||||
func (m Model) bodyHeight() int {
|
||||
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
|
||||
h := m.height - 3 - m.toastRows() - m.toolRows() // header + toolbar + footer + margin
|
||||
if h < 5 {
|
||||
h = 5
|
||||
}
|
||||
@@ -942,7 +957,11 @@ func (m Model) View() tea.View {
|
||||
if t := m.toast.View(max(m.width, 20)); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
parts = append(parts, header, body, footer)
|
||||
parts = append(parts, header)
|
||||
if m.view == viewList {
|
||||
parts = append(parts, m.toolbarLine())
|
||||
}
|
||||
parts = append(parts, body, footer)
|
||||
|
||||
content := m.overlayModal(strings.Join(parts, "\n"))
|
||||
|
||||
@@ -965,30 +984,52 @@ func (m Model) footerPlain() string {
|
||||
if m.animating {
|
||||
anim = fmt.Sprintf("%d", m.fps)
|
||||
}
|
||||
return truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width)
|
||||
full := i18n.Tf("footer.status", hints, anim, m.sound.Status())
|
||||
if lipgloss.Width(full) > m.width {
|
||||
// Hint aksi lebih penting daripada status diagnostik: buang status dulu.
|
||||
full = truncate(hints, m.width)
|
||||
}
|
||||
return full
|
||||
}
|
||||
|
||||
func (m Model) footerLine() string { return dim(m.footerPlain()) }
|
||||
|
||||
// mouseUI — hit-test elemen UI eksplisit terhadap mouse (F-022). Semua yang
|
||||
// tampak seperti tombol/tab/hint HARUS sampai di sini.
|
||||
func (m *Model) mouseUI(x, y int) tea.Cmd {
|
||||
// handled=true berarti klik dikonsumsi (walau tanpa aksi) supaya tidak jatuh
|
||||
// ke pemilihan baris generik di bawahnya.
|
||||
func (m *Model) mouseUI(x, y int) (tea.Cmd, bool) {
|
||||
// 1) Hint footer: klik "[k]" (atau labelnya) menjalankan aksi k.
|
||||
if y == m.height-2 {
|
||||
if key, ok := m.footerKeyAt(x); ok {
|
||||
return m.handleKey(key)
|
||||
return m.handleKey(key), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
// 2) Tab bar detail (view kontainers).
|
||||
// 2) Toolbar workspace (baris di bawah header): pill cari + tombol stack.
|
||||
if m.view == viewList && y == m.headerRows()-1 {
|
||||
return m.toolbarKeyAt(x), true
|
||||
}
|
||||
// 3) Tab bar detail (view kontainers).
|
||||
if m.view == viewList && m.section == sectionContainers && m.sectionLen() > 0 {
|
||||
if idx, ok := m.tabAt(x, y); ok {
|
||||
m.tab = idx
|
||||
m.focus = focusContent
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
return m.restartData(), true
|
||||
}
|
||||
// 3b) Kolom aksi tab (paling kanan): baris = item aksi tab aktif.
|
||||
if _, wAct := m.detailWidths(); wAct > 0 && x >= m.width-wAct {
|
||||
if row := y - (m.headerRows() + 1); row >= 0 {
|
||||
items := m.tabActions()
|
||||
if row < len(items) {
|
||||
return m.handleKey(items[row][0]), true
|
||||
}
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
// 3) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
|
||||
// 4) Toolbar aksi / tombol konfirmasi stack (baris konten pertama detail).
|
||||
if m.view == viewList && m.section == sectionStacks && m.sectionLen() > 0 && y == m.headerRows()+1 {
|
||||
line := ""
|
||||
switch {
|
||||
@@ -999,19 +1040,177 @@ func (m *Model) mouseUI(x, y int) tea.Cmd {
|
||||
}
|
||||
if line != "" {
|
||||
if key, ok := keyZonesAt(line, x-m.leftWidth()-2); ok {
|
||||
return m.handleKey(key)
|
||||
return m.handleKey(key), true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4) Panel beranda (baris kontainer & panel Docker).
|
||||
// 5) Panel beranda (baris kontainer, panel Docker, CTA masuk workspace).
|
||||
if m.view == viewHome {
|
||||
if cmd := m.homeClick(x, y); cmd != nil {
|
||||
return cmd
|
||||
return cmd, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// toolbarPlain — toolbar workspace tanpa gaya warna (sumber koordinat hit-test).
|
||||
func (m Model) toolbarPlain() string {
|
||||
search := "/ " + i18n.T("toolbar.search")
|
||||
if m.searching {
|
||||
search = "/ " + m.query + "▌"
|
||||
}
|
||||
left := "[ " + search + " ]"
|
||||
right := "[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]"
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 2 {
|
||||
return ansiTruncate(left, m.width)
|
||||
}
|
||||
return left + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
func (m Model) toolbarLine() string {
|
||||
th := theme.Current()
|
||||
search := "/ " + i18n.T("toolbar.search")
|
||||
if m.searching {
|
||||
search = "/ " + m.query + "▌"
|
||||
}
|
||||
left := theme.Style(th.Accent).Render("[ " + search + " ]")
|
||||
right := theme.Style(th.Accent).Render(
|
||||
"[ " + i18n.T("toolbar.addStack") + " ] [ " + i18n.T("toolbar.convert") + " ]")
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 2 {
|
||||
return ansiTruncate(left, m.width)
|
||||
}
|
||||
return left + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
// toolbarKeyAt — klik toolbar workspace: pill Cari / tombol stack.
|
||||
func (m *Model) toolbarKeyAt(x int) tea.Cmd {
|
||||
label, ok := labelZoneAt(m.toolbarPlain(), x)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch label {
|
||||
case "/ " + i18n.T("toolbar.search"):
|
||||
m.searching, m.query = true, ""
|
||||
m.focus = focusSidebar
|
||||
m.sound.Play(audio.Click)
|
||||
// shortcut: "+ stack" untuk kini = modal konverter (jalur buat stack yang
|
||||
// ada); ganti saat modal add-stack (editor stack) dibangun.
|
||||
case i18n.T("toolbar.addStack"), i18n.T("toolbar.convert"):
|
||||
return m.handleKey("c")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchKeyMsg — mode cari daftar: ketik = lompat ke item yang cocok.
|
||||
// Esc batal (bersihkan), Enter bertahan di pilihan, Ctrl+C keluar aplikasi.
|
||||
func (m *Model) searchKeyMsg(msg tea.KeyPressMsg) tea.Cmd {
|
||||
switch {
|
||||
case msg.Code == 'c' && msg.Mod&tea.ModCtrl != 0:
|
||||
m.searching = false
|
||||
m.stopLogs()
|
||||
if m.eventsCancel != nil {
|
||||
m.eventsCancel()
|
||||
}
|
||||
m.sound.Play(audio.ModalClose)
|
||||
return tea.Quit
|
||||
case msg.Code == tea.KeyEsc:
|
||||
m.searching, m.query = false, ""
|
||||
m.sound.Play(audio.ModalClose)
|
||||
case msg.Code == tea.KeyEnter:
|
||||
m.searching = false
|
||||
m.sound.Play(audio.Click)
|
||||
case msg.Code == tea.KeyBackspace:
|
||||
if r := []rune(m.query); len(r) > 0 {
|
||||
m.query = string(r[:len(r)-1])
|
||||
}
|
||||
return m.searchJumpCmd()
|
||||
default:
|
||||
if msg.Text != "" {
|
||||
m.query += msg.Text
|
||||
return m.searchJumpCmd()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchJumpCmd — kursor ke item pertama yang namanya memuat query (cari-lompat,
|
||||
// bukan filter: daftar tetap utuh).
|
||||
func (m *Model) searchJumpCmd() tea.Cmd {
|
||||
q := strings.ToLower(strings.TrimSpace(m.query))
|
||||
if q == "" || m.sectionLen() == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < m.sectionLen(); i++ {
|
||||
if strings.Contains(strings.ToLower(m.itemName(i)), q) {
|
||||
if i == m.cur() {
|
||||
return nil
|
||||
}
|
||||
m.setCur(i)
|
||||
m.sound.Play(audio.Navigate)
|
||||
return m.restartData()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// itemName — nama tampil item indeks i (dasar pencarian & pencocokan).
|
||||
func (m Model) itemName(i int) string {
|
||||
switch m.section {
|
||||
case sectionStacks:
|
||||
return m.stacks[i].Name
|
||||
case sectionImages:
|
||||
return imageDisplayName(m.images[i])
|
||||
case sectionVolumes:
|
||||
return m.volumes[i].Name
|
||||
case sectionNetworks:
|
||||
return m.networks[i].Name
|
||||
default:
|
||||
return m.containers[i].Name
|
||||
}
|
||||
}
|
||||
|
||||
// detailWidths — lebar panel detail kontainer & kolom aksi tab (0 = tak tampil
|
||||
// di layar sempit, agar tab bar tetap utuh).
|
||||
func (m Model) detailWidths() (wDetail, wAct int) {
|
||||
w := m.width - m.leftWidth()
|
||||
if m.section == sectionContainers && w >= 66 {
|
||||
wAct = min(max(int(float64(w)*0.22), 15), 20)
|
||||
}
|
||||
return w - wAct, wAct
|
||||
}
|
||||
|
||||
// tabActions — aksi kontekstual tab aktif (kolom kanan, mock "TAB ACTION").
|
||||
func (m Model) tabActions() [][2]string {
|
||||
switch m.tab {
|
||||
case tabLogs:
|
||||
return [][2]string{{"S", i18n.T("tabaction.timestamp")}, {"G", i18n.T("tabaction.follow")}}
|
||||
case tabConfig, tabEnv:
|
||||
return [][2]string{{"G", i18n.T("tabaction.bottom")}}
|
||||
default:
|
||||
return [][2]string{{"r", i18n.T("tabaction.reload")}}
|
||||
}
|
||||
}
|
||||
|
||||
// tabActionLines — baris kolom aksi tab; tinggi WAJIB sama alokasi panel
|
||||
// (hit-test per-baris bergantung padanya).
|
||||
func (m Model) tabActionLines(w, rows int) []string {
|
||||
items := m.tabActions()
|
||||
out := make([]string, 0, rows)
|
||||
for _, it := range items {
|
||||
out = append(out, theme.Style(theme.Current().Accent).Render("["+it[0]+"]")+" "+
|
||||
text(truncate(it[1], w)))
|
||||
}
|
||||
for len(out) < rows {
|
||||
out = append(out, "")
|
||||
}
|
||||
if len(out) > rows {
|
||||
out = out[:rows]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// footerKeyAt — posisi rune x berada di segmen hint mana; kembalikan tombolnya.
|
||||
func (m *Model) footerKeyAt(x int) (string, bool) { return keyZonesAt(m.footerPlain(), x) }
|
||||
|
||||
@@ -1076,42 +1275,6 @@ func (m Model) tabAt(x, y int) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
|
||||
// klik panel Docker → buka bagian kontainer.
|
||||
func (m *Model) homeClick(x, y int) tea.Cmd {
|
||||
W := max(m.width, 60)
|
||||
bodyH := m.bodyHeight()
|
||||
row1H := 9
|
||||
row2H := bodyH - row1H
|
||||
if row2H < 5 {
|
||||
row1H = 6
|
||||
row2H = max(bodyH-row1H, 3)
|
||||
}
|
||||
w1 := int(float64(W) * 0.36)
|
||||
w2 := int(float64(W) * 0.30)
|
||||
k1 := int(float64(W) * 0.55)
|
||||
|
||||
top := m.headerRows() + row1H // border atas panel baris-2
|
||||
first := top + 1 // konten baris pertama (daftar kontainer)
|
||||
|
||||
switch {
|
||||
case y >= first && y < first+max(row2H-2, 0) && x < k1:
|
||||
idx := y - first
|
||||
if len(m.containers) == 0 || idx >= len(m.containers) {
|
||||
return nil
|
||||
}
|
||||
m.jumpSection(sectionContainers)
|
||||
m.setCur(idx)
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2:
|
||||
m.jumpSection(sectionContainers)
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) sidebarView() string {
|
||||
w, h := m.leftWidth(), m.bodyHeight()
|
||||
title := m.sectionName()
|
||||
@@ -1241,25 +1404,31 @@ func (m Model) contentView() string {
|
||||
return panel(s.Name, "", lines, w, h, focused)
|
||||
}
|
||||
|
||||
// Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2).
|
||||
// Kontainer: tab detail + kolom aksi tab di kanan (mock "TAB ACTION").
|
||||
if m.section == sectionContainers {
|
||||
c := m.containers[idx]
|
||||
lines := []string{m.tabBarLine(w - 4)}
|
||||
wDetail, wAct := m.detailWidths()
|
||||
lines := []string{m.tabBarLine(wDetail - 4)}
|
||||
switch m.tab {
|
||||
case tabLogs:
|
||||
lines = append(lines, m.logLines(h-3)...)
|
||||
case tabStats:
|
||||
lines = append(lines, m.statsLines(h-3, w-4)...)
|
||||
lines = append(lines, m.statsLines(h-3, wDetail-4)...)
|
||||
case tabConfig:
|
||||
lines = append(lines, m.inspectLines(h-3, w-4)...)
|
||||
lines = append(lines, m.inspectLines(h-3, wDetail-4)...)
|
||||
case tabEnv:
|
||||
lines = append(lines, m.envLines(h-3, w-4)...)
|
||||
lines = append(lines, m.envLines(h-3, wDetail-4)...)
|
||||
case tabTop:
|
||||
lines = append(lines, m.procLines(h-3, w-4)...)
|
||||
lines = append(lines, m.procLines(h-3, wDetail-4)...)
|
||||
default:
|
||||
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
|
||||
}
|
||||
return panel(c.Name, c.State, lines, w, h, focused)
|
||||
detail := panel(c.Name, c.State, lines, wDetail, h, focused)
|
||||
if wAct == 0 {
|
||||
return detail
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, detail,
|
||||
panel(i18n.T("tab.action"), "", m.tabActionLines(wAct-4, h-2), wAct, h, focused))
|
||||
}
|
||||
|
||||
title, meta := m.sectionName(), ""
|
||||
@@ -1648,6 +1817,12 @@ func (m *Model) handleKey(key string) tea.Cmd {
|
||||
case "c": // modal konverter docker run → compose (PRD §5.1.3)
|
||||
m.modal = newConverterModal()
|
||||
m.sound.Play(audio.ModalOpen)
|
||||
case "/": // mode cari daftar (paritas keyboard pill "Cari" di toolbar)
|
||||
if m.view == viewList {
|
||||
m.searching, m.query = true, ""
|
||||
m.focus = focusSidebar
|
||||
m.sound.Play(audio.Click)
|
||||
}
|
||||
case "u", "s", "R", "p":
|
||||
if m.view == viewList && m.section == sectionStacks {
|
||||
return m.beginStackAction(key)
|
||||
|
||||
+132
-13
@@ -2,31 +2,48 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/version"
|
||||
)
|
||||
|
||||
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador:
|
||||
// grid panel dengan judul di border; baris atas Jam · Docker · Host,
|
||||
// baris bawah Kontainer · Aktivitas.
|
||||
func (m Model) homeBody() string {
|
||||
W := max(m.width, 60)
|
||||
// homeGeom — geometri dashboard (satu sumber untuk render & hit-test klik).
|
||||
// Baris: 3 kartu atas · area bawah (Kontainer | CTA | Aktivitas) · strip bawah.
|
||||
func (m Model) homeGeom() (W, row1H, stripH, row2H, cw, k1, k2 int) {
|
||||
W = max(m.width, 60)
|
||||
bodyH := m.bodyHeight()
|
||||
|
||||
row1H := 9
|
||||
row2H := bodyH - row1H
|
||||
row1H, stripH = 9, 5
|
||||
row2H = bodyH - row1H - stripH
|
||||
if row2H < 5 {
|
||||
row1H = 6
|
||||
row2H = max(bodyH-row1H, 3)
|
||||
row1H, stripH = 6, 4
|
||||
row2H = max(bodyH-row1H-stripH, 3)
|
||||
}
|
||||
|
||||
cw = min(max(int(float64(W)*0.20), 18), 26) // kolom tengah: CTA masuk workspace
|
||||
k1 = (W - cw) / 2
|
||||
k2 = W - cw - k1
|
||||
return
|
||||
}
|
||||
|
||||
// homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador dengan
|
||||
// kolom tengah CTA ala mock pengguna: kartu Jam · Engine · Host di atas,
|
||||
// Kontainer | CTA | Aktivitas di tengah, strip Disclaimer & Info aplikasi di bawah.
|
||||
func (m Model) homeBody() string {
|
||||
W, row1H, stripH, row2H, cw, k1, k2 := m.homeGeom()
|
||||
|
||||
w1 := int(float64(W) * 0.36)
|
||||
w2 := int(float64(W) * 0.30)
|
||||
w3 := W - w1 - w2
|
||||
@@ -37,16 +54,118 @@ func (m Model) homeBody() string {
|
||||
m.hostPanel(w3, row1H),
|
||||
)
|
||||
|
||||
k1 := int(float64(W) * 0.55)
|
||||
k2 := W - k1
|
||||
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
left := lipgloss.JoinVertical(lipgloss.Left,
|
||||
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
|
||||
m.miniContainerLines(row2H-2), k1, row2H, false),
|
||||
m.disclaimerPanel(k1, stripH),
|
||||
)
|
||||
mid, _, _ := m.centerColumn(cw, row2H+stripH)
|
||||
right := lipgloss.JoinVertical(lipgloss.Left,
|
||||
panel(i18n.T("section.activity"), fmt.Sprintf("(%d)", len(m.activity)),
|
||||
m.activityLines(k2-4, row2H-2), k2, row2H, false),
|
||||
m.appsPanel(k2, stripH),
|
||||
)
|
||||
|
||||
return row1 + "\n" + row2
|
||||
return row1 + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Join(mid, "\n"), right)
|
||||
}
|
||||
|
||||
// centerColumn — kolom tengah: kotak CTA "masuk workspace" (klikable; paritas
|
||||
// dengan tombol yang tampil). Kembalikan juga posisi kotak untuk hit-test.
|
||||
func (m Model) centerColumn(w, h int) (lines []string, ctaTop, ctaH int) {
|
||||
th := theme.Current()
|
||||
border := theme.Style(th.Accent)
|
||||
label := theme.Style(th.Accent).Bold(true)
|
||||
|
||||
inner := max(w-4, 8)
|
||||
rows := wrapText(i18n.T("home.enterWorkspace"), inner)
|
||||
ctaH = len(rows) + 2
|
||||
box := []string{border.Render("╭" + strings.Repeat("─", w-2) + "╮")}
|
||||
for _, r := range rows {
|
||||
pad := inner - lipgloss.Width(r)
|
||||
l := max(pad/2, 0)
|
||||
box = append(box, border.Render("│")+" "+strings.Repeat(" ", l)+
|
||||
label.Render(r)+strings.Repeat(" ", max(pad-l, 0))+" "+border.Render("│"))
|
||||
}
|
||||
box = append(box, border.Render("╰"+strings.Repeat("─", w-2)+"╯"))
|
||||
|
||||
ctaTop = max((h-ctaH)/2, 0)
|
||||
lines = make([]string, 0, h)
|
||||
for i := 0; i < h; i++ {
|
||||
idx := i - ctaTop
|
||||
switch {
|
||||
case idx >= 0 && idx < ctaH:
|
||||
lines = append(lines, box[idx])
|
||||
default:
|
||||
lines = append(lines, strings.Repeat(" ", w))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// disclaimerPanel — strip peringatan aksi destruktif.
|
||||
func (m Model) disclaimerPanel(w, h int) string {
|
||||
rows := wrapText(i18n.T("home.disclaimer"), max(w-4, 8))
|
||||
if len(rows) > h-2 {
|
||||
rows = rows[:h-2]
|
||||
}
|
||||
return panel(i18n.T("section.disclaimer"), "", rows, w, h, false)
|
||||
}
|
||||
|
||||
const repoURL = "gitea.oxpoe.qzz.io/oxpoe/tia-love"
|
||||
|
||||
// appsPanel — strip info aplikasi: versi, repo, lokasi config.
|
||||
func (m Model) appsPanel(w, h int) string {
|
||||
rows := []string{
|
||||
fieldW(i18n.T("field.version"), 7) + dim(truncate(version.String(), w-9)),
|
||||
fieldW(i18n.T("field.repo"), 7) + dim(truncate(repoURL, w-9)),
|
||||
fieldW(i18n.T("field.config"), 7) + dim(truncate(prettyPath(config.Path()), w-9)),
|
||||
}
|
||||
if len(rows) > h-2 {
|
||||
rows = rows[:h-2]
|
||||
}
|
||||
return panel(i18n.T("section.appsInfo"), "", rows, w, h, false)
|
||||
}
|
||||
|
||||
// prettyPath — tampilkan path config dengan "~" untuk home pengguna.
|
||||
func prettyPath(p string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil && home != "" && strings.HasPrefix(p, home+"/") {
|
||||
return "~" + strings.TrimPrefix(p, home)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// homeClick — panel beranda: baris kontainer → buka workspace pada item itu;
|
||||
// panel Engine → bagian kontainer; kotak CTA → workspace (Stacks).
|
||||
func (m *Model) homeClick(x, y int) tea.Cmd {
|
||||
W, row1H, stripH, row2H, cw, k1, _ := m.homeGeom()
|
||||
w1 := int(float64(W) * 0.36)
|
||||
w2 := int(float64(W) * 0.30)
|
||||
|
||||
top := m.headerRows() + row1H // baris pertama area bawah
|
||||
first := top + 1 // konten baris pertama (daftar kontainer)
|
||||
|
||||
_, ctaTop, ctaH := m.centerColumn(cw, row2H+stripH)
|
||||
switch {
|
||||
case y >= first && y < first+max(row2H-2, 0) && x < k1:
|
||||
idx := y - first
|
||||
if len(m.containers) == 0 || idx >= len(m.containers) {
|
||||
return nil
|
||||
}
|
||||
m.jumpSection(sectionContainers)
|
||||
m.setCur(idx)
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
case y >= m.headerRows()+1 && y < m.headerRows()+row1H && x >= w1 && x < w1+w2:
|
||||
m.jumpSection(sectionContainers)
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
case y >= top+ctaTop && y < top+ctaTop+ctaH && x >= k1 && x < k1+cw:
|
||||
m.jumpSection(sectionStacks)
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartData()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) clockPanel(w, h int) string {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
)
|
||||
|
||||
// TestDetailWidths — kolom aksi tab tampil hanya bila layar cukup lebar
|
||||
// (tab bar detail tetap utuh di layar sempit).
|
||||
func TestDetailWidths(t *testing.T) {
|
||||
m := Model{width: 120, section: sectionContainers}
|
||||
wDetail, wAct := m.detailWidths()
|
||||
if wAct == 0 {
|
||||
t.Fatalf("layar 120: kolom aksi harus tampil (wDetail=%d)", wDetail)
|
||||
}
|
||||
if wDetail+wAct != 120-m.leftWidth() {
|
||||
t.Fatalf("layar 120: lebar tidak menjumlah (wDetail=%d wAct=%d)", wDetail, wAct)
|
||||
}
|
||||
m.width = 80
|
||||
if _, wAct = m.detailWidths(); wAct != 0 {
|
||||
t.Fatalf("layar 80: kolom aksi harus disembunyikan, dapat %d", wAct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolbarPlain — toolbar workspace memuat pill cari + tombol stack,
|
||||
// menampilkan query saat mode cari, dan tidak melebihi lebar layar.
|
||||
func TestToolbarPlain(t *testing.T) {
|
||||
m := Model{width: 80, view: viewList}
|
||||
line := m.toolbarPlain()
|
||||
if !strings.Contains(line, "[ / "+i18n.T("toolbar.search")+" ]") {
|
||||
t.Fatalf("pill cari tidak ada: %q", line)
|
||||
}
|
||||
if !strings.Contains(line, i18n.T("toolbar.addStack")) || !strings.Contains(line, i18n.T("toolbar.convert")) {
|
||||
t.Fatalf("tombol stack/konversi tidak ada: %q", line)
|
||||
}
|
||||
if w := lipgloss.Width(line); w > 80 {
|
||||
t.Fatalf("toolbar melebihi lebar layar: %d", w)
|
||||
}
|
||||
m.searching, m.query = true, "abc"
|
||||
if !strings.Contains(m.toolbarPlain(), "/ abc") {
|
||||
t.Fatalf("query tidak tampil di pill: %q", m.toolbarPlain())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchJump — pencarian melompat ke item pertama yang cocok (daftar tetap
|
||||
// utuh), dan nama item mengikuti seksi aktif.
|
||||
func TestSearchJump(t *testing.T) {
|
||||
m := Model{
|
||||
section: sectionContainers,
|
||||
containers: []docker.Container{{Name: "alfa"}, {Name: "beta"}, {Name: "betawi"}},
|
||||
}
|
||||
m.query = "beta"
|
||||
m.searchJumpCmd()
|
||||
if got := m.cur(); got != 1 {
|
||||
t.Fatalf("kursor = %d, mau 1 (kecocokan pertama)", got)
|
||||
}
|
||||
if m.itemName(2) != "betawi" {
|
||||
t.Fatalf("itemName(2) = %q", m.itemName(2))
|
||||
}
|
||||
m.query = "tidak-ada"
|
||||
m.searchJumpCmd()
|
||||
if got := m.cur(); got != 1 {
|
||||
t.Fatalf("tanpa kecocokan kursor harus tetap: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,9 @@ func (e *Engine) WaitReady(d time.Duration) bool {
|
||||
|
||||
// Status ringkas untuk status bar / `tia-love doctor`.
|
||||
func (e *Engine) Status() string {
|
||||
if e == nil { // Engine zero-value aman (Model tanpa audio.New)
|
||||
return "nonaktif"
|
||||
}
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
switch {
|
||||
@@ -144,7 +147,7 @@ func (e *Engine) Status() string {
|
||||
// Play memutar efek; fire-and-forget, aman dipanggil dari goroutine mana pun.
|
||||
// No-op bila engine belum siap / dimatikan / muted.
|
||||
func (e *Engine) Play(ev Event) {
|
||||
if e.muted || !e.opts.Enabled {
|
||||
if e == nil || e.muted || !e.opts.Enabled {
|
||||
return
|
||||
}
|
||||
e.mu.RLock()
|
||||
|
||||
@@ -15,6 +15,17 @@ section:
|
||||
networks: "Networks"
|
||||
activity: "Activity"
|
||||
detail: "Detail"
|
||||
disclaimer: "Disclaimer"
|
||||
appsInfo: "App info"
|
||||
|
||||
home:
|
||||
enterWorkspace: "enter workspace"
|
||||
disclaimer: "Destructive actions (bring a stack down, delete resources) cannot be undone — double-check."
|
||||
|
||||
toolbar:
|
||||
search: "Search"
|
||||
addStack: "+ stack"
|
||||
convert: "convert"
|
||||
|
||||
tab:
|
||||
logs: "Logs"
|
||||
@@ -22,6 +33,13 @@ tab:
|
||||
config: "Config"
|
||||
env: "Env"
|
||||
top: "Top"
|
||||
action: "Tab actions"
|
||||
|
||||
tabaction:
|
||||
timestamp: "timestamp"
|
||||
follow: "follow tail"
|
||||
bottom: "to bottom"
|
||||
reload: "reload"
|
||||
|
||||
stats:
|
||||
history: "CPU history (60s)"
|
||||
@@ -86,6 +104,9 @@ field:
|
||||
inUse: "In use"
|
||||
dir: "Dir"
|
||||
composeFile: "Compose"
|
||||
version: "Version"
|
||||
repo: "Repo"
|
||||
config: "Config"
|
||||
|
||||
state:
|
||||
loading: "loading…"
|
||||
@@ -124,8 +145,8 @@ hint:
|
||||
inspectWaiting: "fetching inspect…"
|
||||
procsWaiting: "fetching processes…"
|
||||
footerHome: "[1/enter] stacks · [t] toast · [T] theme · [c] convert · [a] reduced-motion · [q] quit"
|
||||
footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [c] convert · [j/k] select · [0] home · [q] quit"
|
||||
footerContainers: "[1-5] sections · [j/k] select · [Tab] focus · [h/l] tab · [c] convert · [r] reload · [0] home · [T] theme · [q] quit"
|
||||
footerStacks: "[u] start · [s] stop · [R] restart · [p] update · [D] down · [j/k] select · [/] search · [0] home · [q] quit"
|
||||
footerContainers: "[1-5] sections · [j/k] select · [/] search · [Tab] focus · [h/l] tab · [T] theme · [r] reload · [0] home · [q] quit"
|
||||
|
||||
footer:
|
||||
status: "%s animation %s fps · audio: %s"
|
||||
|
||||
@@ -15,6 +15,17 @@ section:
|
||||
networks: "Jaringan"
|
||||
activity: "Aktivitas"
|
||||
detail: "Detail"
|
||||
disclaimer: "Disclaimer"
|
||||
appsInfo: "Info aplikasi"
|
||||
|
||||
home:
|
||||
enterWorkspace: "masuk workspace"
|
||||
disclaimer: "Aksi destruktif (turunkan stack, hapus resource) tidak bisa dibatalkan — periksa dua kali."
|
||||
|
||||
toolbar:
|
||||
search: "Cari"
|
||||
addStack: "+ stack"
|
||||
convert: "konversi"
|
||||
|
||||
tab:
|
||||
logs: "Log"
|
||||
@@ -22,6 +33,13 @@ tab:
|
||||
config: "Konfigurasi"
|
||||
env: "Env"
|
||||
top: "Proses"
|
||||
action: "Aksi Tab"
|
||||
|
||||
tabaction:
|
||||
timestamp: "timestamp"
|
||||
follow: "ikut ekor"
|
||||
bottom: "ke bawah"
|
||||
reload: "muat ulang"
|
||||
|
||||
stats:
|
||||
history: "Riwayat CPU (60 dtk)"
|
||||
@@ -86,6 +104,9 @@ field:
|
||||
inUse: "Dipakai"
|
||||
dir: "Dir"
|
||||
composeFile: "Compose"
|
||||
version: "Versi"
|
||||
repo: "Repo"
|
||||
config: "Config"
|
||||
|
||||
state:
|
||||
loading: "memuat…"
|
||||
@@ -124,8 +145,8 @@ hint:
|
||||
inspectWaiting: "mengambil inspect…"
|
||||
procsWaiting: "mengambil daftar proses…"
|
||||
footerHome: "[1/enter] stack · [t] toast · [T] tema · [c] konversi · [a] reduced-motion · [q] keluar"
|
||||
footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [c] konversi · [j/k] pilih · [0] beranda · [q] keluar"
|
||||
footerContainers: "[1-5] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [c] konversi · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
|
||||
footerStacks: "[u] jalankan · [s] henti · [R] ulang · [p] perbarui · [D] turunkan · [j/k] pilih · [/] cari · [0] beranda · [q] keluar"
|
||||
footerContainers: "[1-5] bagian · [j/k] pilih · [/] cari · [Tab] fokus · [h/l] tab · [T] tema · [r] muat ulang · [0] beranda · [q] keluar"
|
||||
|
||||
footer:
|
||||
status: "%s animasi %s fps · audio: %s"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package version — string versi & build info (dipakai CLI dan TUI).
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// String memakai build info VCS — tanpa ldflags.
|
||||
func String() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "tia dev"
|
||||
}
|
||||
ver := info.Main.Version
|
||||
if ver == "" || ver == "(devel)" || strings.HasPrefix(ver, "v0.0.0-") {
|
||||
ver = "dev"
|
||||
}
|
||||
rev, dirty := "", false
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if len(s.Value) >= 7 {
|
||||
rev = s.Value[:7]
|
||||
}
|
||||
case "vcs.modified":
|
||||
dirty = s.Value == "true"
|
||||
}
|
||||
}
|
||||
out := "tia " + ver
|
||||
if rev != "" {
|
||||
out += " (" + rev
|
||||
if dirty {
|
||||
out += "-dirty"
|
||||
}
|
||||
out += ")"
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user