Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,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
Expand Down
11 changes: 6 additions & 5 deletions cmd/top.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -146,6 +155,9 @@ func Get() *Config {
OutputFormat: "table",
SortBy: "",
},
TUI: TUIConfig{
RememberState: false,
},
}
}
return config
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions internal/state/state.go
Original file line number Diff line number Diff line change
@@ -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,
}
}

Loading
Loading