From 23005fe3e49e5bb82684179a53c04eca79a8e353 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Thu, 25 Dec 2025 17:47:53 +0100 Subject: [PATCH] feat(tui): add option to remember view state between sessions --- README.md | 15 +++ cmd/top.go | 11 +- internal/config/config.go | 17 +++ internal/state/state.go | 133 ++++++++++++++++++++ internal/state/state_test.go | 236 +++++++++++++++++++++++++++++++++++ internal/tui/keys.go | 10 ++ internal/tui/model.go | 83 +++++++++--- 7 files changed, 483 insertions(+), 22 deletions(-) create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go diff --git a/README.md b/README.md index f974ea8..6640a3b 100644 --- a/README.md +++ b/README.md @@ -222,8 +222,23 @@ optional config file at `~/.config/snitch/snitch.toml`: numeric = false # disable name resolution dns_cache = true # cache dns lookups (set to false to disable) theme = "auto" # color theme: auto, dark, light, mono + +[tui] +remember_state = false # remember view options between sessions ``` +### remembering view options + +when `remember_state = true`, the tui will save and restore: + +- filter toggles (tcp/udp, listen/established/other) +- sort field and direction +- address and port resolution settings + +state is saved to `$XDG_STATE_HOME/snitch/tui.json` (defaults to `~/.local/state/snitch/tui.json`). + +cli flags always take priority over saved state. + ### environment variables ```bash diff --git a/cmd/top.go b/cmd/top.go index 90b55a9..eb9d7c0 100644 --- a/cmd/top.go +++ b/cmd/top.go @@ -33,11 +33,12 @@ var topCmd = &cobra.Command{ resolver.SetNoCache(effectiveNoCache) opts := tui.Options{ - Theme: theme, - Interval: topInterval, - ResolveAddrs: resolveAddrs, - ResolvePorts: resolvePorts, - NoCache: effectiveNoCache, + Theme: theme, + Interval: topInterval, + ResolveAddrs: resolveAddrs, + ResolvePorts: resolvePorts, + NoCache: effectiveNoCache, + RememberState: cfg.TUI.RememberState, } // if any filter flag is set, use exclusive mode diff --git a/internal/config/config.go b/internal/config/config.go index 64f5e87..b60fd02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,12 @@ import ( // Config represents the application configuration type Config struct { Defaults DefaultConfig `mapstructure:"defaults"` + TUI TUIConfig `mapstructure:"tui"` +} + +// TUIConfig contains TUI-specific configuration +type TUIConfig struct { + RememberState bool `mapstructure:"remember_state"` } // DefaultConfig contains default values for CLI options @@ -105,6 +111,9 @@ func setDefaults(v *viper.Viper) { v.SetDefault("defaults.no_headers", false) v.SetDefault("defaults.output_format", "table") v.SetDefault("defaults.sort_by", "") + + // tui settings + v.SetDefault("tui.remember_state", false) } func handleSpecialEnvVars(v *viper.Viper) { @@ -146,6 +155,9 @@ func Get() *Config { OutputFormat: "table", SortBy: "", }, + TUI: TUIConfig{ + RememberState: false, + }, } } return config @@ -199,6 +211,11 @@ ipv6 = false no_headers = false output_format = "table" sort_by = "" + +[tui] +# remember view options (filters, sort, resolution) between sessions +# state is saved to $XDG_STATE_HOME/snitch/tui.json +remember_state = false `, themeList, theme.DefaultTheme) // Ensure directory exists diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..0df5c74 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,133 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "github.com/karol-broda/snitch/internal/collector" +) + +// TUIState holds view options that can be persisted between sessions +type TUIState struct { + ShowTCP bool `json:"show_tcp"` + ShowUDP bool `json:"show_udp"` + ShowListening bool `json:"show_listening"` + ShowEstablished bool `json:"show_established"` + ShowOther bool `json:"show_other"` + SortField collector.SortField `json:"sort_field"` + SortReverse bool `json:"sort_reverse"` + ResolveAddrs bool `json:"resolve_addrs"` + ResolvePorts bool `json:"resolve_ports"` +} + +var ( + saveMu sync.Mutex + saveChan chan TUIState + once sync.Once +) + +// Path returns the XDG-compliant state file path +func Path() string { + stateDir := os.Getenv("XDG_STATE_HOME") + if stateDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + stateDir = filepath.Join(home, ".local", "state") + } + return filepath.Join(stateDir, "snitch", "tui.json") +} + +// Load reads the TUI state from disk. +// returns nil if state file doesn't exist or can't be read. +func Load() *TUIState { + path := Path() + if path == "" { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var state TUIState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + + return &state +} + +// Save writes the TUI state to disk synchronously. +// creates parent directories if needed. +func Save(state TUIState) error { + path := Path() + if path == "" { + return nil + } + + saveMu.Lock() + defer saveMu.Unlock() + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// SaveAsync queues a state save to happen in the background. +// only the most recent state is saved if multiple saves are queued. +func SaveAsync(state TUIState) { + once.Do(func() { + saveChan = make(chan TUIState, 1) + go saveWorker() + }) + + // non-blocking send, replace pending save with newer state + select { + case saveChan <- state: + default: + // channel full, drain and replace + select { + case <-saveChan: + default: + } + select { + case saveChan <- state: + default: + } + } +} + +func saveWorker() { + for state := range saveChan { + _ = Save(state) + } +} + +// Default returns a TUIState with default values +func Default() TUIState { + return TUIState{ + ShowTCP: true, + ShowUDP: true, + ShowListening: true, + ShowEstablished: true, + ShowOther: true, + SortField: collector.SortByLport, + SortReverse: false, + ResolveAddrs: false, + ResolvePorts: false, + } +} + diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..69856cc --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,236 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/karol-broda/snitch/internal/collector" +) + +func TestPath_XDGStateHome(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "/custom/state") + path := Path() + + expected := "/custom/state/snitch/tui.json" + if path != expected { + t.Errorf("Path() = %q, want %q", path, expected) + } +} + +func TestPath_DefaultFallback(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "") + path := Path() + + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home directory") + } + + expected := filepath.Join(home, ".local", "state", "snitch", "tui.json") + if path != expected { + t.Errorf("Path() = %q, want %q", path, expected) + } +} + +func TestDefault(t *testing.T) { + d := Default() + + if d.ShowTCP != true { + t.Error("expected ShowTCP to be true") + } + if d.ShowUDP != true { + t.Error("expected ShowUDP to be true") + } + if d.ShowListening != true { + t.Error("expected ShowListening to be true") + } + if d.ShowEstablished != true { + t.Error("expected ShowEstablished to be true") + } + if d.ShowOther != true { + t.Error("expected ShowOther to be true") + } + if d.SortField != collector.SortByLport { + t.Errorf("expected SortField to be %q, got %q", collector.SortByLport, d.SortField) + } + if d.SortReverse != false { + t.Error("expected SortReverse to be false") + } + if d.ResolveAddrs != false { + t.Error("expected ResolveAddrs to be false") + } + if d.ResolvePorts != false { + t.Error("expected ResolvePorts to be false") + } +} + +func TestSaveAndLoad(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + state := TUIState{ + ShowTCP: false, + ShowUDP: true, + ShowListening: true, + ShowEstablished: false, + ShowOther: true, + SortField: collector.SortByProcess, + SortReverse: true, + ResolveAddrs: true, + ResolvePorts: false, + } + + err := Save(state) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // verify file was created + path := Path() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("expected state file to exist after Save()") + } + + loaded := Load() + if loaded == nil { + t.Fatal("Load() returned nil") + } + + if loaded.ShowTCP != state.ShowTCP { + t.Errorf("ShowTCP = %v, want %v", loaded.ShowTCP, state.ShowTCP) + } + if loaded.ShowUDP != state.ShowUDP { + t.Errorf("ShowUDP = %v, want %v", loaded.ShowUDP, state.ShowUDP) + } + if loaded.ShowListening != state.ShowListening { + t.Errorf("ShowListening = %v, want %v", loaded.ShowListening, state.ShowListening) + } + if loaded.ShowEstablished != state.ShowEstablished { + t.Errorf("ShowEstablished = %v, want %v", loaded.ShowEstablished, state.ShowEstablished) + } + if loaded.ShowOther != state.ShowOther { + t.Errorf("ShowOther = %v, want %v", loaded.ShowOther, state.ShowOther) + } + if loaded.SortField != state.SortField { + t.Errorf("SortField = %v, want %v", loaded.SortField, state.SortField) + } + if loaded.SortReverse != state.SortReverse { + t.Errorf("SortReverse = %v, want %v", loaded.SortReverse, state.SortReverse) + } + if loaded.ResolveAddrs != state.ResolveAddrs { + t.Errorf("ResolveAddrs = %v, want %v", loaded.ResolveAddrs, state.ResolveAddrs) + } + if loaded.ResolvePorts != state.ResolvePorts { + t.Errorf("ResolvePorts = %v, want %v", loaded.ResolvePorts, state.ResolvePorts) + } +} + +func TestLoad_NonExistent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + loaded := Load() + if loaded != nil { + t.Error("expected Load() to return nil for non-existent file") + } +} + +func TestLoad_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // create directory and invalid json file + stateDir := filepath.Join(tmpDir, "snitch") + if err := os.MkdirAll(stateDir, 0755); err != nil { + t.Fatal(err) + } + stateFile := filepath.Join(stateDir, "tui.json") + if err := os.WriteFile(stateFile, []byte("not valid json"), 0644); err != nil { + t.Fatal(err) + } + + loaded := Load() + if loaded != nil { + t.Error("expected Load() to return nil for invalid JSON") + } +} + +func TestSave_CreatesDirectories(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // snitch directory should not exist yet + snitchDir := filepath.Join(tmpDir, "snitch") + if _, err := os.Stat(snitchDir); err == nil { + t.Fatal("expected snitch directory to not exist initially") + } + + err := Save(Default()) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + + // directory should now exist + if _, err := os.Stat(snitchDir); os.IsNotExist(err) { + t.Error("expected Save() to create parent directories") + } +} + +func TestSaveAsync(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + state := TUIState{ + ShowTCP: false, + SortField: collector.SortByPID, + } + + SaveAsync(state) + + // wait for background save with timeout + deadline := time.Now().Add(100 * time.Millisecond) + for time.Now().Before(deadline) { + if loaded := Load(); loaded != nil { + return + } + time.Sleep(5 * time.Millisecond) + } + + t.Log("SaveAsync may not have completed in time (non-fatal in CI)") +} + +func TestTUIState_JSONRoundtrip(t *testing.T) { + // verify all sort fields serialize correctly + sortFields := []collector.SortField{ + collector.SortByLport, + collector.SortByProcess, + collector.SortByPID, + collector.SortByState, + collector.SortByProto, + } + + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + for _, sf := range sortFields { + state := TUIState{ + ShowTCP: true, + SortField: sf, + } + + if err := Save(state); err != nil { + t.Fatalf("Save() error for %q: %v", sf, err) + } + + loaded := Load() + if loaded == nil { + t.Fatalf("Load() returned nil for %q", sf) + } + + if loaded.SortField != sf { + t.Errorf("SortField roundtrip failed: got %q, want %q", loaded.SortField, sf) + } + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 0921afc..cdafc65 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -118,31 +118,39 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "t": m.showTCP = !m.showTCP m.clampCursor() + m.saveState() case "u": m.showUDP = !m.showUDP m.clampCursor() + m.saveState() case "l": m.showListening = !m.showListening m.clampCursor() + m.saveState() case "e": m.showEstablished = !m.showEstablished m.clampCursor() + m.saveState() case "o": m.showOther = !m.showOther m.clampCursor() + m.saveState() case "a": m.showTCP = true m.showUDP = true m.showListening = true m.showEstablished = true m.showOther = true + m.saveState() // sorting case "s": m.cycleSort() + m.saveState() case "S": m.sortReverse = !m.sortReverse m.applySorting() + m.saveState() // search case "/": @@ -220,6 +228,7 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.statusMessage = "address resolution: off" } m.statusExpiry = time.Now().Add(2 * time.Second) + m.saveState() return m, clearStatusAfter(2 * time.Second) // toggle port resolution @@ -231,6 +240,7 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.statusMessage = "port resolution: off" } m.statusExpiry = time.Now().Add(2 * time.Second) + m.saveState() return m, clearStatusAfter(2 * time.Second) } diff --git a/internal/tui/model.go b/internal/tui/model.go index d3c9e3f..b95aeb3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,11 +2,13 @@ package tui import ( "fmt" - "github.com/karol-broda/snitch/internal/collector" - "github.com/karol-broda/snitch/internal/theme" "time" tea "github.com/charmbracelet/bubbletea" + + "github.com/karol-broda/snitch/internal/collector" + "github.com/karol-broda/snitch/internal/state" + "github.com/karol-broda/snitch/internal/theme" ) type model struct { @@ -51,20 +53,24 @@ type model struct { // status message (temporary feedback) statusMessage string statusExpiry time.Time + + // state persistence + rememberState bool } type Options struct { - Theme string - Interval time.Duration - TCP bool - UDP bool - Listening bool - Established bool - Other bool - FilterSet bool // true if user specified any filter flags - ResolveAddrs bool // when true, resolve IP addresses to hostnames - ResolvePorts bool // when true, resolve port numbers to service names - NoCache bool // when true, disable DNS caching + Theme string + Interval time.Duration + TCP bool + UDP bool + Listening bool + Established bool + Other bool + FilterSet bool // true if user specified any filter flags + ResolveAddrs bool // when true, resolve IP addresses to hostnames + ResolvePorts bool // when true, resolve port numbers to service names + NoCache bool // when true, disable DNS caching + RememberState bool // when true, persist view options between sessions } func New(opts Options) model { @@ -79,8 +85,27 @@ func New(opts Options) model { showListening := true showEstablished := true showOther := true + sortField := collector.SortByLport + sortReverse := false + resolveAddrs := opts.ResolveAddrs + resolvePorts := opts.ResolvePorts - // if user specified filters, use those instead + // load saved state if enabled and no CLI filter flags were specified + if opts.RememberState && !opts.FilterSet { + if saved := state.Load(); saved != nil { + showTCP = saved.ShowTCP + showUDP = saved.ShowUDP + showListening = saved.ShowListening + showEstablished = saved.ShowEstablished + showOther = saved.ShowOther + sortField = saved.SortField + sortReverse = saved.SortReverse + resolveAddrs = saved.ResolveAddrs + resolvePorts = saved.ResolvePorts + } + } + + // if user specified filters, use those instead (CLI flags take precedence) if opts.FilterSet { showTCP = opts.TCP showUDP = opts.UDP @@ -108,13 +133,15 @@ func New(opts Options) model { showListening: showListening, showEstablished: showEstablished, showOther: showOther, - sortField: collector.SortByLport, - resolveAddrs: opts.ResolveAddrs, - resolvePorts: opts.ResolvePorts, + sortField: sortField, + sortReverse: sortReverse, + resolveAddrs: resolveAddrs, + resolvePorts: resolvePorts, theme: theme.GetTheme(opts.Theme), interval: interval, lastRefresh: time.Now(), watchedPIDs: make(map[int]bool), + rememberState: opts.RememberState, } } @@ -291,3 +318,25 @@ func (m *model) toggleWatch(pid int) { func (m model) watchedCount() int { return len(m.watchedPIDs) } + +// currentState returns the current view options as a TUIState for persistence +func (m model) currentState() state.TUIState { + return state.TUIState{ + ShowTCP: m.showTCP, + ShowUDP: m.showUDP, + ShowListening: m.showListening, + ShowEstablished: m.showEstablished, + ShowOther: m.showOther, + SortField: m.sortField, + SortReverse: m.sortReverse, + ResolveAddrs: m.resolveAddrs, + ResolvePorts: m.resolvePorts, + } +} + +// saveState persists current view options in the background +func (m model) saveState() { + if m.rememberState { + state.SaveAsync(m.currentState()) + } +}