feat(tabs): Proses (docker top) + Konfigurasi (JSON inspect, scroll) + Env (KEY=value) — Phase 2 tuntas
CI / test (push) Successful in 23s
CI / test (push) Successful in 23s
This commit is contained in:
1 parent
86db90ea9a
commit
177e27910b
7 files changed
+419
-17
No files matched your search
+78
-3
@@ -348,6 +348,82 @@ func TestStatsTab(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessTab — tab Proses: tabel `docker top` tampil.
|
||||
func TestProcessTab(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)
|
||||
}
|
||||
name := items[0].Name
|
||||
if len(name) > 12 {
|
||||
name = name[:12]
|
||||
}
|
||||
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
u.send("1")
|
||||
u.waitContains(name, 5*time.Second)
|
||||
u.send("h") // dari Log membungkus ke tab Proses (paling akhir)
|
||||
u.waitContains("PID", 6*time.Second)
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigEnvTabs — tab Konfigurasi (JSON inspect) & Env (KEY=value).
|
||||
func TestConfigEnvTabs(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)
|
||||
}
|
||||
name := items[0].Name
|
||||
if len(name) > 12 {
|
||||
name = name[:12]
|
||||
}
|
||||
env, err := cl.InspectEnv(ctx, items[0].ID)
|
||||
if err != nil || len(env) == 0 {
|
||||
t.Skipf("env kosong (err=%v)", err)
|
||||
}
|
||||
firstKey, _, _ := strings.Cut(env[0], "=")
|
||||
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
u.send("1")
|
||||
u.waitContains(name, 5*time.Second)
|
||||
u.send("l") // Stats
|
||||
u.send("l") // Konfigurasi
|
||||
u.waitContains(`"Id"`, 6*time.Second)
|
||||
u.send("l") // Env
|
||||
u.waitContains(firstKey+"=", 6*time.Second)
|
||||
u.send("k") // scroll dokumen tidak crash
|
||||
u.send("G")
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeCycle(t *testing.T) {
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
@@ -477,9 +553,8 @@ func TestLogTab(t *testing.T) {
|
||||
u.send("j")
|
||||
u.send("G")
|
||||
u.send("l") // tab berikutnya (Statistik)
|
||||
u.send("l") // lalu Konfigurasi → placeholder
|
||||
u.waitContains("menyusul", 3*time.Second)
|
||||
u.send("h") // kembali ke tab Log
|
||||
u.send("l") // Konfigurasi — kini data nyata: JSON inspect
|
||||
u.waitContains(`"Id"`, 6*time.Second)
|
||||
u.send("\x1b[<0;60;10M\x1b[<0;60;10m") // klik area konten = fokus
|
||||
|
||||
u.send("q")
|
||||
|
||||
+231
-14
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -112,6 +113,21 @@ type Model struct {
|
||||
stats []docker.Stats
|
||||
statsID int64
|
||||
|
||||
inspectID int64
|
||||
inspectJSON []byte
|
||||
inspectErr error
|
||||
|
||||
envID int64
|
||||
env []string
|
||||
envErr error
|
||||
|
||||
procsID int64
|
||||
procsTitle []string
|
||||
procsRows [][]string
|
||||
procsErr error
|
||||
|
||||
detailScroll int
|
||||
|
||||
hostname string
|
||||
sys sysinfo.Stats
|
||||
|
||||
@@ -328,6 +344,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
c := m.containers[min(m.cur(), m.sectionLen()-1)]
|
||||
return m, fetchStats(m.statsID, m.cl, c.ID)
|
||||
|
||||
case inspectMsg:
|
||||
if msg.id != m.inspectID {
|
||||
return m, nil
|
||||
}
|
||||
m.inspectErr = msg.err
|
||||
m.inspectJSON = msg.data
|
||||
m.detailScroll = 0
|
||||
return m, nil
|
||||
|
||||
case envMsg:
|
||||
if msg.id != m.envID {
|
||||
return m, nil
|
||||
}
|
||||
m.envErr = msg.err
|
||||
m.env = msg.env
|
||||
m.detailScroll = 0
|
||||
return m, nil
|
||||
|
||||
case procsMsg:
|
||||
if msg.id != m.procsID {
|
||||
return m, nil
|
||||
}
|
||||
m.procsErr = msg.err
|
||||
m.procsTitle, m.procsRows = msg.titles, msg.rows
|
||||
return m, nil
|
||||
|
||||
case tea.BackgroundColorMsg:
|
||||
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
|
||||
theme.SetDetectedDark(msg.IsDark())
|
||||
@@ -416,9 +458,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.view == viewList && m.section == sectionContainers {
|
||||
return m, m.cycleTab(1)
|
||||
}
|
||||
case "G": // kembali ke ekor log (live)
|
||||
case "G": // ekor log (live) / dasar dokumen (config & env)
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
m.logOffset = 0
|
||||
switch m.tab {
|
||||
case tabConfig, tabEnv:
|
||||
m.detailScroll = m.detailScrollMax()
|
||||
default:
|
||||
m.logOffset = 0
|
||||
}
|
||||
m.sound.Play(audio.Click)
|
||||
}
|
||||
case "S": // toggle timestamp log
|
||||
@@ -432,8 +479,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.restartData()
|
||||
case "j", "down":
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
if m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
switch m.tab {
|
||||
case tabConfig, tabEnv:
|
||||
if m.detailScroll < m.detailScrollMax() {
|
||||
m.detailScroll++
|
||||
}
|
||||
default: // tab Log
|
||||
if m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
}
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
break
|
||||
@@ -445,8 +499,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.restartData()
|
||||
case "k", "up":
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
if m.logOffset < len(m.logs)-1 {
|
||||
m.logOffset++
|
||||
switch m.tab {
|
||||
case tabConfig, tabEnv:
|
||||
if m.detailScroll > 0 {
|
||||
m.detailScroll--
|
||||
}
|
||||
default: // tab Log
|
||||
if m.logOffset < len(m.logs)-1 {
|
||||
m.logOffset++
|
||||
}
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
break
|
||||
@@ -485,13 +546,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.view != viewList || m.focus != focusContent {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
if m.logOffset < len(m.logs)-1 {
|
||||
m.logOffset++
|
||||
up := msg.Button == tea.MouseWheelUp
|
||||
switch m.tab {
|
||||
case tabConfig, tabEnv:
|
||||
if up && m.detailScroll < m.detailScrollMax() {
|
||||
m.detailScroll++
|
||||
} else if !up && m.detailScroll > 0 {
|
||||
m.detailScroll--
|
||||
}
|
||||
case tea.MouseWheelDown:
|
||||
if m.logOffset > 0 {
|
||||
default: // tab Log
|
||||
if up && m.logOffset < len(m.logs)-1 {
|
||||
m.logOffset++
|
||||
} else if !up && m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
}
|
||||
}
|
||||
@@ -583,9 +649,38 @@ func (m *Model) restartStats() tea.Cmd {
|
||||
return fetchStats(m.statsID, m.cl, c.ID)
|
||||
}
|
||||
|
||||
// restartData — restart semua sumber data detail (log + stats) untuk seleksi kini.
|
||||
// restartData — restart semua sumber data detail (log + stats + inspect/env/
|
||||
// proses) untuk seleksi saat ini; masing-masing menyaring sendiri konteksnya.
|
||||
func (m *Model) restartData() tea.Cmd {
|
||||
return tea.Batch(m.restartLogs(), m.restartStats())
|
||||
return tea.Batch(
|
||||
m.restartLogs(), m.restartStats(),
|
||||
m.restartInspect(), m.restartEnv(), m.restartProcs(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) detailCmdFor(tab int, fetch func(id int64, cl *docker.Client, cid string) tea.Cmd, id *int64) tea.Cmd {
|
||||
*id++
|
||||
if m.view != viewList || m.section != sectionContainers || m.tab != tab ||
|
||||
m.cl == nil || m.sectionLen() == 0 {
|
||||
return nil
|
||||
}
|
||||
c := m.containers[min(m.cur(), m.sectionLen()-1)]
|
||||
return fetch(*id, m.cl, c.ID)
|
||||
}
|
||||
|
||||
func (m *Model) restartInspect() tea.Cmd {
|
||||
m.inspectJSON, m.inspectErr = nil, nil
|
||||
return m.detailCmdFor(tabConfig, fetchInspect, &m.inspectID)
|
||||
}
|
||||
|
||||
func (m *Model) restartEnv() tea.Cmd {
|
||||
m.env, m.envErr = nil, nil
|
||||
return m.detailCmdFor(tabEnv, fetchEnv, &m.envID)
|
||||
}
|
||||
|
||||
func (m *Model) restartProcs() tea.Cmd {
|
||||
m.procsTitle, m.procsRows, m.procsErr = nil, nil, nil
|
||||
return m.detailCmdFor(tabTop, fetchProcs, &m.procsID)
|
||||
}
|
||||
|
||||
// --- data helpers ---
|
||||
@@ -930,6 +1025,12 @@ func (m Model) contentView() string {
|
||||
lines = append(lines, m.logLines(h-3)...)
|
||||
case tabStats:
|
||||
lines = append(lines, m.statsLines(h-3, w-4)...)
|
||||
case tabConfig:
|
||||
lines = append(lines, m.inspectLines(h-3, w-4)...)
|
||||
case tabEnv:
|
||||
lines = append(lines, m.envLines(h-3, w-4)...)
|
||||
case tabTop:
|
||||
lines = append(lines, m.procLines(h-3, w-4)...)
|
||||
default:
|
||||
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
|
||||
}
|
||||
@@ -1021,6 +1122,122 @@ func (m Model) logLines(rows int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// detailScrollMax — batas scroll dokumen (tab Config/Env) dalam baris.
|
||||
func (m Model) detailScrollMax() int {
|
||||
rows := m.bodyHeight() - 3
|
||||
var total int
|
||||
switch m.tab {
|
||||
case tabConfig:
|
||||
if len(m.inspectJSON) > 0 {
|
||||
total = bytes.Count(m.inspectJSON, []byte("\n")) + 1
|
||||
}
|
||||
case tabEnv:
|
||||
total = len(m.env)
|
||||
}
|
||||
if total <= rows {
|
||||
return 0
|
||||
}
|
||||
return total - rows
|
||||
}
|
||||
|
||||
// inspectLines — JSON inspect mentah (scroll via detailScroll).
|
||||
func (m Model) inspectLines(rows, w int) []string {
|
||||
if m.inspectErr != nil {
|
||||
return []string{red(truncate(m.inspectErr.Error(), w))}
|
||||
}
|
||||
if len(m.inspectJSON) == 0 {
|
||||
return []string{dim(i18n.T("hint.inspectWaiting"))}
|
||||
}
|
||||
all := strings.Split(string(m.inspectJSON), "\n")
|
||||
out := make([]string, 0, rows)
|
||||
for i := m.detailScroll; i < len(all) && len(out) < rows; i++ {
|
||||
out = append(out, all[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// envLines — KEY=value; kunci terang, nilai redup.
|
||||
func (m Model) envLines(rows, w int) []string {
|
||||
if m.envErr != nil {
|
||||
return []string{red(truncate(m.envErr.Error(), w))}
|
||||
}
|
||||
if m.env == nil {
|
||||
return []string{dim(i18n.T("hint.inspectWaiting"))}
|
||||
}
|
||||
out := make([]string, 0, rows)
|
||||
for i := m.detailScroll; i < len(m.env) && len(out) < rows; i++ {
|
||||
kv := m.env[i]
|
||||
if k, v, ok := strings.Cut(kv, "="); ok {
|
||||
out = append(out, text(k+"=")+dim(truncate(v, max(1, w-len([]rune(k))-1))))
|
||||
} else {
|
||||
out = append(out, text(kv))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// procLines — tabel proses (`docker top`): header redup, kolom dipadatkan,
|
||||
// kolom terakhir (perintah) mengambil sisa lebar.
|
||||
func (m Model) procLines(rows, w int) []string {
|
||||
if m.procsErr != nil {
|
||||
return []string{red(truncate(m.procsErr.Error(), w))}
|
||||
}
|
||||
cols := len(m.procsTitle)
|
||||
if cols == 0 {
|
||||
return []string{dim(i18n.T("hint.procsWaiting"))}
|
||||
}
|
||||
widths := make([]int, cols)
|
||||
for i, t := range m.procsTitle {
|
||||
widths[i] = len([]rune(t))
|
||||
}
|
||||
for _, r := range m.procsRows {
|
||||
for i, c := range r {
|
||||
if i < cols && len([]rune(c)) > widths[i] {
|
||||
widths[i] = len([]rune(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < cols-1; i++ {
|
||||
if widths[i] > 14 {
|
||||
widths[i] = 14
|
||||
}
|
||||
}
|
||||
used := 0
|
||||
for i := 0; i < cols-1; i++ {
|
||||
used += widths[i] + 2
|
||||
}
|
||||
widths[cols-1] = max(10, w-used)
|
||||
|
||||
row := func(cells []string, style func(string) string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < cols; i++ {
|
||||
c := ""
|
||||
if i < len(cells) {
|
||||
c = cells[i]
|
||||
}
|
||||
if r := []rune(c); len(r) > widths[i] {
|
||||
c = string(r[:max(1, widths[i]-1)]) + "…"
|
||||
}
|
||||
b.WriteString(style(fmt.Sprintf("%-*s", widths[i], c)))
|
||||
if i < cols-1 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
out := []string{row(m.procsTitle, dim)}
|
||||
maxRows := rows - 1
|
||||
for i, r := range m.procsRows {
|
||||
if i >= maxRows {
|
||||
out = append(out, dim(fmt.Sprintf("… +%d", len(m.procsRows)-i)))
|
||||
break
|
||||
}
|
||||
out = append(out, row(r, text))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logLevelRE — token level umum di log (INFO/WARN/ERRO/…).
|
||||
var logLevelRE = regexp.MustCompile(`\b(TRACE|TRAC|DEBUG|DEBU|INFO|WARN|WARNING|ERRO|ERROR|FATAL|FATA)\b`)
|
||||
|
||||
|
||||
@@ -48,6 +48,52 @@ func statsNext(id int64) tea.Cmd {
|
||||
return tea.Tick(statsInterval, func(time.Time) tea.Msg { return statsTickMsg{id: id} })
|
||||
}
|
||||
|
||||
// inspectMsg / envMsg / procsMsg — data tab Konfigurasi / Env / Proses
|
||||
// (one-shot saat tab dibuka atau `r`).
|
||||
type inspectMsg struct {
|
||||
id int64
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
type envMsg struct {
|
||||
id int64
|
||||
env []string
|
||||
err error
|
||||
}
|
||||
type procsMsg struct {
|
||||
id int64
|
||||
titles []string
|
||||
rows [][]string
|
||||
err error
|
||||
}
|
||||
|
||||
func fetchInspect(id int64, cl *docker.Client, cid string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
b, err := cl.InspectJSON(ctx, cid)
|
||||
return inspectMsg{id: id, data: b, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchEnv(id int64, cl *docker.Client, cid string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
env, err := cl.InspectEnv(ctx, cid)
|
||||
return envMsg{id: id, env: env, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProcs(id int64, cl *docker.Client, cid string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
titles, rows, err := cl.TopProcesses(ctx, cid)
|
||||
return procsMsg{id: id, titles: titles, rows: rows, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
logBatchMax = 200 // maksimum baris per batch
|
||||
logIdle = 200 * time.Millisecond // tunggu baris pertama
|
||||
|
||||
@@ -61,4 +61,23 @@ func TestIntegrationInfo(t *testing.T) {
|
||||
t.Fatalf("ListNetworks: %v", err)
|
||||
}
|
||||
t.Logf("images=%d volumes=%d networks=%d", len(images), len(volumes), len(networks))
|
||||
|
||||
// Inspect & top: pakai kontainer yang benar-benar berjalan.
|
||||
for _, ct := range items {
|
||||
if ct.State != "running" {
|
||||
continue
|
||||
}
|
||||
if b, err := c.InspectJSON(ctx, ct.ID); err != nil || len(b) == 0 {
|
||||
t.Fatalf("InspectJSON(%s): err=%v len=%d", ct.Name, err, len(b))
|
||||
}
|
||||
if env, err := c.InspectEnv(ctx, ct.ID); err != nil || len(env) == 0 {
|
||||
t.Fatalf("InspectEnv(%s): err=%v n=%d", ct.Name, err, len(env))
|
||||
}
|
||||
titles, rows, err := c.TopProcesses(ctx, ct.ID)
|
||||
if err != nil || len(titles) == 0 || len(rows) == 0 {
|
||||
t.Fatalf("TopProcesses(%s): err=%v titles=%d rows=%d", ct.Name, err, len(titles), len(rows))
|
||||
}
|
||||
t.Logf("inspect+env+top OK untuk %s (%d proses)", ct.Name, len(rows))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// InspectJSON — inspect kontainer sebagai JSON rapi (tab Konfigurasi).
|
||||
func (c *Client) InspectJSON(ctx context.Context, id string) ([]byte, error) {
|
||||
in, err := c.cli.ContainerInspect(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: inspect: %w", err)
|
||||
}
|
||||
b, err := json.MarshalIndent(in, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: marshal inspect: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// InspectEnv — variabel environment kontainer (tab Env).
|
||||
func (c *Client) InspectEnv(ctx context.Context, id string) ([]string, error) {
|
||||
in, err := c.cli.ContainerInspect(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: inspect env: %w", err)
|
||||
}
|
||||
if in.Config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return in.Config.Env, nil
|
||||
}
|
||||
|
||||
// TopProcesses — daftar proses di dalam kontainer (`docker top`).
|
||||
func (c *Client) TopProcesses(ctx context.Context, id string) ([]string, [][]string, error) {
|
||||
resp, err := c.cli.ContainerTop(ctx, id, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("docker: top: %w", err)
|
||||
}
|
||||
return resp.Titles, resp.Processes, nil
|
||||
}
|
||||
@@ -91,6 +91,8 @@ hint:
|
||||
logsWaiting: "waiting for logs…"
|
||||
logsEnd: "log stream ended"
|
||||
statsWaiting: "fetching stats…"
|
||||
inspectWaiting: "fetching inspect…"
|
||||
procsWaiting: "fetching processes…"
|
||||
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
|
||||
footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit"
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ hint:
|
||||
logsWaiting: "menunggu log…"
|
||||
logsEnd: "stream log berakhir"
|
||||
statsWaiting: "mengambil statistik…"
|
||||
inspectWaiting: "mengambil inspect…"
|
||||
procsWaiting: "mengambil daftar proses…"
|
||||
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
|
||||
footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
|
||||
|
||||
|
||||
Reference in new issue
Block a user