30 Commits

Author SHA1 Message Date
Hadi a75b65644f v0.0.8
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:18:31 +02:00
Hadi cc0176a73d feat: add "flag" toggle in replay & findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:15:51 +02:00
Hadi ec0fb61099 add filter in ui/replay
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:57:25 +02:00
Hadi ee3e6806c7 fix: jumping cursor on tick when filter is set
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:30:22 +02:00
Hadi 36eab35576 feat: add "Host" header
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:24:40 +02:00
Hadi 8211d87379 fix: overlaping keys in textarea
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:10:33 +02:00
Hadi ef1789af2f edit default keybindings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 11:56:39 +02:00
Hadi 31bb2aa439 v0.0.7
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 01:15:40 +02:00
Hadi b3caa9852c feat: add pre-configured librewolf browser
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 01:12:55 +02:00
Hadi 39248e2be6 fix: bad path
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:45:49 +02:00
Hadi 9d0ef598a7 Per-project configuration overwrites
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:14:54 +02:00
Hadi 77b102791f edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:14:35 +02:00
Hadi 2e0ad98a4a remove useless icons
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 22:26:09 +02:00
Hadi b0afb7cb56 add the ssl-insecure flag
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 22:15:58 +02:00
Hadi acee3df1bc fix: add ssl_insecure, expand values, edit keybindings, ...
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 21:59:54 +02:00
Hadi 61a112a302 add logs
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 13:41:09 +02:00
Hadi 677223c9b0 change logs fmt
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 11:54:26 +02:00
Hadi 098400ba77 pflag -> cobra: Sub commands, completions, ...
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 11:36:46 +02:00
Hadi 4922f3704d fix: make empty text responsive
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 11:13:57 +02:00
Hadi 1aa4b92bb3 add titles
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 10:51:10 +02:00
Hadi 437e8f883d fix: govulncheck & fix vulns
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 10:46:32 +02:00
Hadi 5c74eda48b fix: gosec vulns
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 10:29:18 +02:00
Hadi 1190276bab add gosec
Signed-off-by: Hadi <hadi@example.com>
2026-05-29 10:06:32 +02:00
Hadi 6128dcc15e Add ko-fi link
Signed-off-by: Hadi <hadi@example.com>
2026-05-27 14:57:52 +02:00
Hadi 41d5366952 edit badges
Signed-off-by: Hadi <hadi@example.com>
2026-05-27 14:56:17 +02:00
Hadi 8683d17242 Add fatal error when port is already in use
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 22:44:31 +02:00
Hadi 2e61ffa125 Add ilovetui instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 20:01:41 +02:00
Hadi 9ac6040629 Migrate components to ilovetui
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 16:38:09 +02:00
Hadi c7523e69e7 add vim/neovim integration instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 16:15:07 +02:00
Hadi aa458c142f use ilovetui colors & styles 2026-05-26 16:12:10 +02:00
72 changed files with 1255 additions and 1164 deletions
+39 -8
View File
@@ -9,10 +9,10 @@
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
> Think Burp Suite or Caido, but entirely in your terminal.
[![Go Version](https://img.shields.io/github/go-mod/go-version/anotherhadi/spilltea)](go.mod)
[![Release](https://img.shields.io/github/v/release/anotherhadi/spilltea)](https://github.com/anotherhadi/spilltea/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/spilltea)](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
![GitHub Stars](https://www.shieldcn.dev/github/stars/anotherhadi/spilltea.svg?variant=outline&theme=violet)
![Release](https://www.shieldcn.dev/github/release/anotherhadi/spilltea.svg?variant=outline&theme=violet)
![CI](https://www.shieldcn.dev/github/ci/anotherhadi/spilltea.svg?variant=outline&theme=violet)
[![Ko-fi](https://www.shieldcn.dev/badge/Ko--fi-sponsor-FF5E5B.svg?logo=kofi&variant=secondary&theme=violet)](https://ko-fi.com/anotherhadi)
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
@@ -23,8 +23,10 @@
- [Installation](#installation)
- [Project Management](#project-management)
- [Configuration](#configuration)
- [Per-project configuration](#per-project-configuration)
- [CLI Flags](#cli-flags)
- [Plugin System](#plugin-system)
- [Vim / Neovim Integration](#vim--neovim-integration)
- [Deployment](#deployment)
- [Tech Stack](#tech-stack)
@@ -106,29 +108,49 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
On startup, you choose:
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
- **Existing project**: pick from a list of previous projects
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
### Per-project configuration
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
Only the keys present in that file are overridden; everything else falls back to the global config.
The priority order is:
1. Global config (`~/.config/spilltea/config.yaml`)
2. Project config (`<project-dir>/config.yaml`)
3. CLI flags (always win)
## CLI Flags
```
Usage: spilltea [flags]
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
Usage:
spilltea [flags]
Flags:
--add-default-config copy the default config file to the config path and exit
--add-default-plugins copy built-in example plugins into the plugins dir and exit
-c, --config string path to config file
-h, --help help for spilltea
--host string proxy host (overrides config)
--plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session
--ssl-insecure skip TLS certificate verification (overrides config)
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version print version
-v, --version version for spilltea
```
<!-- endexec -->
@@ -137,6 +159,15 @@ Usage: spilltea [flags]
Spilltea supports plugins written in **Lua**. Plugins are loaded from `~/.config/spilltea/plugins/` by default and do not require recompilation or access to the source code.
For a full reference and examples, see the [plugin documentation](./docs/plugins.md) or [plugin examples](./plugins/).
## Vim / Neovim Integration
Spilltea pairs naturally with Neovim. If you live in your editor, these plugins let you inspect and transform captured traffic without switching context:
- **[sttr.nvim](https://github.com/anotherhadi/sttr.nvim)**: Run string transformations (base64, URL encode/decode, hex, hashing, and more) on any selected text directly from Neovim. Handy for decoding values spotted in the proxy history.
- **[jwt-tui.nvim](https://github.com/anotherhadi/jwt-tui.nvim)**: Decode and inspect JWT tokens in a floating TUI window. Great for quickly reading tokens intercepted through the proxy.
Both plugins are built to feel native in a terminal workflow, just like Spilltea.
## Deployment
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
+140 -111
View File
@@ -15,7 +15,7 @@ import (
"github.com/anotherhadi/spilltea/internal/style"
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
flag "github.com/spf13/pflag"
"github.com/spf13/cobra"
)
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
@@ -32,121 +32,150 @@ func init() {
func main() {
var (
flagConfig = flag.StringP("config", "c", "", "path to config file")
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
flagHost = flag.String("host", "", "proxy host (overrides config)")
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
flagConfig string
flagPluginsDir string
flagHost string
flagPort int
flagUpstreamProxy string
flagProject string
flagSslInsecure bool
flagAddDefaultPlugins bool
flagAddDefaultConfig bool
)
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = func() {
fmt.Println("Usage: spilltea [flags]\n")
flag.PrintDefaults()
}
flag.Parse()
if *flagVersion {
fmt.Println(version)
os.Exit(0)
rootCmd := &cobra.Command{
Use: "spilltea",
Short: "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.",
Version: version,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
if flagAddDefaultPlugins {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if flagConfig != "" {
cfgPath = flagConfig
}
if err := config.Load(cfgPath); err != nil {
return fmt.Errorf("config: %w", err)
}
dir := config.ExpandPath(config.Global.App.PluginsDir)
if flagPluginsDir != "" {
dir = flagPluginsDir
}
n, err := spilltea.InstallDefaultPlugins(dir)
if err != nil {
return fmt.Errorf("add-default-plugins: %w", err)
}
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
return nil
}
if flagAddDefaultConfig {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if flagConfig != "" {
cfgPath = flagConfig
}
if err := config.WriteDefaultConfig(cfgPath); err != nil {
return fmt.Errorf("add-default-config: %w", err)
}
fmt.Printf("default config written to %s\n", cfgPath)
return nil
}
if flagProject != "" && !homeUI.IsValidProjectName(flagProject) {
return fmt.Errorf("project: invalid name %q (only lowercase letters, digits, - and _ are allowed)", flagProject)
}
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if flagConfig != "" {
cfgPath = flagConfig
}
if err := config.Load(cfgPath); err != nil {
return fmt.Errorf("config: %w", err)
}
config.Global.Version = version
sslInsecureChanged := cmd.Flags().Changed("ssl-insecure")
applyCLI := func(c *config.Config) {
if flagPluginsDir != "" {
c.App.PluginsDir = flagPluginsDir
}
if flagHost != "" {
c.App.Host = flagHost
}
if flagPort != 0 {
c.App.Port = flagPort
}
if flagUpstreamProxy != "" {
c.App.UpstreamProxy = flagUpstreamProxy
}
if sslInsecureChanged {
c.App.SslInsecure = flagSslInsecure
}
}
applyCLI(config.Global)
config.SetCLIOverrides(applyCLI)
style.Init()
icons.Init(config.Global)
keys.Init(config.Global)
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
if flagProject != "" {
project, err := homeUI.OpenProject(projectDir, flagProject)
if err != nil {
return fmt.Errorf("project: %w", err)
}
if err := config.MergeProjectConfig(filepath.Dir(project.Path)); err != nil {
return fmt.Errorf("project config: %w", err)
}
icons.Init(config.Global)
keys.Init(config.Global)
broker := intercept.NewBroker()
m := appUI.New(broker, project.Name, project.Path)
final, err := tea.NewProgram(m).Run()
if err != nil {
return fmt.Errorf("tui: %w", err)
}
if app, ok := final.(appUI.Model); ok {
if ferr := app.FatalErr(); ferr != nil {
return ferr
}
}
return nil
}
root := rootModel{home: homeUI.New(projectDir)}
final, err := tea.NewProgram(root).Run()
if err != nil {
return fmt.Errorf("tui: %w", err)
}
if r, ok := final.(rootModel); ok {
if ferr := r.FatalErr(); ferr != nil {
return ferr
}
}
return nil
},
}
if *flagAddDefaultPlugins {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.Load(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
dir := config.ExpandPath(config.Global.App.PluginsDir)
if *flagPluginsDir != "" {
dir = *flagPluginsDir
}
n, err := spilltea.InstallDefaultPlugins(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
os.Exit(1)
}
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
os.Exit(0)
}
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
rootCmd.Flags().StringVar(&flagPluginsDir, "plugins-dir", "", "path to plugins dir (overrides config)")
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
rootCmd.Flags().BoolVar(&flagSslInsecure, "ssl-insecure", false, "skip TLS certificate verification (overrides config)")
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
if *flagAddDefaultConfig {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.WriteDefaultConfig(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "add-default-config: %v\n", err)
os.Exit(1)
}
fmt.Printf("default config written to %s\n", cfgPath)
os.Exit(0)
}
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
os.Exit(1)
}
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.Load(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
config.Global.Version = version
if *flagPluginsDir != "" {
config.Global.App.PluginsDir = *flagPluginsDir
}
if *flagHost != "" {
config.Global.App.Host = *flagHost
}
if *flagPort != 0 {
config.Global.App.Port = *flagPort
}
if *flagUpstreamProxy != "" {
config.Global.App.UpstreamProxy = *flagUpstreamProxy
}
style.Init(config.Global)
icons.Init(config.Global)
keys.Init(config.Global)
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
// If --project flag is set, skip the home screen entirely.
if *flagProject != "" {
project, err := homeUI.OpenProject(projectDir, *flagProject)
if err != nil {
fmt.Fprintf(os.Stderr, "project: %v\n", err)
os.Exit(1)
}
broker := intercept.NewBroker()
m := appUI.New(broker, project.Name, project.Path)
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
os.Exit(1)
}
return
}
// Run home + app in a single program to avoid a blank flash on transition.
root := rootModel{home: homeUI.New(projectDir)}
if _, err := tea.NewProgram(root).Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+20
View File
@@ -1,8 +1,14 @@
package main
import (
"log"
"path/filepath"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
)
@@ -34,6 +40,11 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.state == rootStateHome {
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
if err := config.MergeProjectConfig(filepath.Dir(sel.Project.Path)); err != nil {
log.Printf("project config: %v", err)
}
icons.Init(config.Global)
keys.Init(config.Global)
broker := intercept.NewBroker()
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
m.app = app
@@ -58,3 +69,12 @@ func (m rootModel) View() tea.View {
}
return m.home.View()
}
func (m rootModel) FatalErr() error {
if m.state == rootStateApp {
if app, ok := m.app.(appUI.Model); ok {
return app.FatalErr()
}
}
return nil
}
+24 -4
View File
@@ -4,29 +4,49 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
On startup, you choose:
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
- **Existing project**: pick from a list of previous projects
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot!
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
### Per-project configuration
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
Only the keys present in that file are overridden; everything else falls back to the global config.
The priority order is:
1. Global config (`~/.config/spilltea/config.yaml`)
2. Project config (`<project-dir>/config.yaml`)
3. CLI flags (always win)
## CLI Flags
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
```
Usage: spilltea [flags]
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
Usage:
spilltea [flags]
Flags:
--add-default-config copy the default config file to the config path and exit
--add-default-plugins copy built-in example plugins into the plugins dir and exit
-c, --config string path to config file
-h, --help help for spilltea
--host string proxy host (overrides config)
--plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session
--ssl-insecure skip TLS certificate verification (overrides config)
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version print version
-v, --version version for spilltea
```
<!-- endexec -->
+2
View File
@@ -11,5 +11,7 @@
# Spilltea Documentation
- **Version**: `{{.Cfg.Version}}`
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
- **Repository**: `https://github.com/anotherhadi/spilltea`
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
+1 -1
View File
@@ -155,7 +155,7 @@ If the user **dismisses** a finding it is permanently hidden and will never reap
## Configuration
Plugin configuration is stored in a `plugins.yaml` file alongside the project database.
Plugin configuration is stored in a `plugins.yaml` file alongside the project database and can be edited through the "Proxy" TUI page.
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
```yaml
+7 -1
View File
@@ -1,6 +1,12 @@
## Configuring your browser's proxy settings
We recommend installing **FoxyProxy** to manage your browser's proxies.
**If you use Nix**, you can launch a pre-configured browser (LibreWolf) with the proxy and certificate already set up:
```sh
nix run github:anotherhadi/spilltea#browser{{if or (ne .Cfg.App.Host "127.0.0.1") (ne .Cfg.App.Port 8080) (ne .Cfg.App.CertDir "~/.local/share/spilltea")}} --{{end}}{{if ne .Cfg.App.Host "127.0.0.1"}} --host {{.Cfg.App.Host}}{{end}}{{if ne .Cfg.App.Port 8080}} --port {{.Cfg.App.Port}}{{end}}{{if ne .Cfg.App.CertDir "~/.local/share/spilltea"}} --cert-dir {{.Cfg.App.CertDir}}{{end}}
```
Otherwise, we recommend installing **FoxyProxy** to manage your browser's proxies.
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
+7 -4
View File
@@ -7,20 +7,22 @@ require (
charm.land/bubbletea/v2 v2.0.6
charm.land/glamour/v2 v2.0.0
charm.land/lipgloss/v2 v2.0.3
github.com/andybalholm/brotli v1.0.4
github.com/anotherhadi/ilovetui v0.1.6
github.com/charmbracelet/x/ansi v0.11.7
github.com/klauspost/compress v1.17.8
github.com/lqqyt2423/go-mitmproxy v1.8.11
github.com/sirupsen/logrus v1.9.4
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/yuin/gopher-lua v1.1.2
golang.org/x/net v0.54.0
golang.org/x/net v0.55.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
)
require (
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
@@ -39,7 +41,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
@@ -54,6 +56,7 @@ require (
github.com/satori/go.uuid v1.2.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect
@@ -61,7 +64,7 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
+13 -4
View File
@@ -16,6 +16,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY=
github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
@@ -42,6 +44,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
@@ -70,6 +73,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -102,6 +107,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
@@ -114,6 +120,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -138,12 +147,12 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-20
View File
@@ -1,20 +0,0 @@
package config
type Colors struct {
Base00 string `mapstructure:"base00"`
Base01 string `mapstructure:"base01"`
Base02 string `mapstructure:"base02"`
Base03 string `mapstructure:"base03"`
Base04 string `mapstructure:"base04"`
Base05 string `mapstructure:"base05"`
Base06 string `mapstructure:"base06"`
Base07 string `mapstructure:"base07"`
Base08 string `mapstructure:"base08"`
Base09 string `mapstructure:"base09"`
Base0A string `mapstructure:"base0a"`
Base0B string `mapstructure:"base0b"`
Base0C string `mapstructure:"base0c"`
Base0D string `mapstructure:"base0d"`
Base0E string `mapstructure:"base0e"`
Base0F string `mapstructure:"base0f"`
}
+56 -3
View File
@@ -28,10 +28,10 @@ type Config struct {
ProxyAuth string `mapstructure:"proxy_auth"`
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
ExternalEditor string `mapstructure:"external_editor"`
SslInsecure bool `mapstructure:"ssl_insecure"`
} `mapstructure:"app"`
TUI struct {
Colors Colors `mapstructure:"colors"`
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
@@ -65,6 +65,43 @@ type GlobalPlugin struct {
var Global *Config
var projectCLIOverrides func(*Config)
// SetCLIOverrides stores a function that re-applies CLI flag overrides.
// It is called automatically after MergeProjectConfig so that CLI flags
// always win over both global and per-project config.
func SetCLIOverrides(fn func(*Config)) {
projectCLIOverrides = fn
}
// MergeProjectConfig merges <projectDir>/config.yaml on top of the current
// Global config, then re-applies any registered CLI overrides.
// If no config.yaml exists in the project directory, this is a no-op.
func MergeProjectConfig(projectDir string) error {
projectConfigPath := filepath.Join(projectDir, "config.yaml")
if _, err := os.Stat(projectConfigPath); errors.Is(err, os.ErrNotExist) {
return nil
}
pv := viper.New()
pv.SetConfigFile(projectConfigPath)
if err := pv.ReadInConfig(); err != nil {
return fmt.Errorf("project config: %w", err)
}
if err := viper.MergeConfigMap(pv.AllSettings()); err != nil {
return fmt.Errorf("merge project config: %w", err)
}
if err := viper.Unmarshal(Global); err != nil {
return err
}
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
if projectCLIOverrides != nil {
projectCLIOverrides(Global)
}
return nil
}
func Load(path string) error {
var defaults map[string]any
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
@@ -83,11 +120,16 @@ func Load(path string) error {
}
Global = &Config{}
return viper.Unmarshal(Global)
if err := viper.Unmarshal(Global); err != nil {
return err
}
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
return nil
}
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
@@ -107,6 +149,17 @@ func ExpandPath(p string) string {
return p
}
// expandEnvValue replaces a value starting with "$" with the corresponding
// environment variable, enabling secrets to be kept out of config files.
func expandEnvValue(s string) string {
if len(s) > 1 && s[0] == '$' {
if val := os.Getenv(s[1:]); val != "" {
return val
}
}
return s
}
func flatten(prefix string, m map[string]any) map[string]any {
out := make(map[string]any)
for k, v := range m {
+16 -27
View File
@@ -4,15 +4,16 @@ app:
cert_dir: ~/.local/share/spilltea
project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled).
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets)
intercept:
default_intercept_enabled: true
default_capture_response: false
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
@@ -27,33 +28,17 @@ tui:
use_nerdfont_icons: false
default_sidebar_state: "expanded" # hidden, collapsed, expanded
pretty_print_body: true # auto-indent JSON and HTML response bodies
colors:
base00: "110F12" # Default Background
base01: "1C1920" # Lighter Background (status bars, line numbers)
base02: "1D1A26" # Selection Background
base03: "514D63" # Comments, Invisibles, faint text
base04: "8E8AA0" # Dark Foreground (status bars)
base05: "C2BED6" # Default Foreground, Caret, Delimiters
base06: "D8D5EA" # Light Foreground (rarely used)
base07: "EAE7F7" # Light Background (rarely used)
base08: "E07080" # Red: errors, diff deleted
base09: "D49070" # Orange: integers, constants, warnings
base0a: "C4B060" # Yellow: classes, search highlight
base0b: "80B880" # Green: strings, diff inserted, success
base0c: "70B8C0" # Cyan: support, regex, escape chars
base0d: "9E97F8" # Blue/Accent: functions, headings, primary
base0e: "C090E8" # Purple: keywords, storage
base0f: "D080A0" # Pink: deprecated, embedded language tags
keybindings:
global:
quit: "q,ctrl+c"
escape: "esc,ctrl+c"
help: "?"
open_logs: "ctrl+g"
toggle_sidebar: "ctrl+b"
cycle_focus: "tab"
send_to_replay: "ctrl+r"
send_to_diff: "ctrl+d"
send_to_diff: "ctrl+x"
copy_as: "ctrl+y"
copy: "y"
up: "up,k"
@@ -62,10 +47,10 @@ keybindings:
right: "right,l"
goto_top: "g"
goto_bottom: "G,end"
scroll_up: "pgup"
scroll_down: "pgdown"
prev_page: "["
next_page: "]"
scroll_up: "pgup,ctrl+u"
scroll_down: "pgdown,ctrl+d"
prev_page: "[,ctrl+i"
next_page: "],ctrl+o"
intercept:
toggle_intercept: "i"
@@ -76,7 +61,7 @@ keybindings:
drop_all: "D"
edit: "e,enter"
edit_external: "E"
undo_edits: "ctrl+z"
undo_edits: "u,ctrl+z"
history:
delete_entry: "x"
@@ -94,15 +79,19 @@ keybindings:
send: "s, enter"
edit: "e"
edit_external: "E"
undo_edits: "ctrl+z"
undo_edits: "u,ctrl+z"
delete_entry: "x"
delete_all: "X"
filter: "/"
flag: "m"
diff:
clear: "x"
findings:
dismiss: "x"
dismiss_all: "X"
flag: "m"
plugins:
toggle: "space"
+6 -1
View File
@@ -2,6 +2,7 @@ package config
type GlobalKeys struct {
Quit string `mapstructure:"quit"`
Escape string `mapstructure:"escape"`
OpenLogs string `mapstructure:"open_logs"`
ToggleSidebar string `mapstructure:"toggle_sidebar"`
Help string `mapstructure:"help"`
@@ -55,6 +56,8 @@ type ReplayKeys struct {
UndoEdits string `mapstructure:"undo_edits"`
Delete string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"`
Flag string `mapstructure:"flag"`
}
type DiffKeys struct {
@@ -62,7 +65,9 @@ type DiffKeys struct {
}
type FindingsKeys struct {
Dismiss string `mapstructure:"dismiss"`
Dismiss string `mapstructure:"dismiss"`
DismissAll string `mapstructure:"dismiss_all"`
Flag string `mapstructure:"flag"`
}
type PluginsKeys struct {
+19 -6
View File
@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"log"
"sync"
_ "modernc.org/sqlite"
@@ -25,17 +26,25 @@ func Open(path string) (*DB, error) {
conn.SetMaxOpenConns(1)
d := &DB{conn: conn, path: path}
if err := d.migrate(); err != nil {
conn.Close()
if cerr := conn.Close(); cerr != nil {
log.Printf("db: close conn: %v", cerr)
}
return nil, err
}
roConn, err := sql.Open("sqlite", path)
if err != nil {
conn.Close()
if cerr := conn.Close(); cerr != nil {
log.Printf("db: close conn: %v", cerr)
}
return nil, err
}
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
conn.Close()
roConn.Close()
if cerr := conn.Close(); cerr != nil {
log.Printf("db: close conn: %v", cerr)
}
if cerr := roConn.Close(); cerr != nil {
log.Printf("db: close roConn: %v", cerr)
}
return nil, err
}
d.roConn = roConn
@@ -70,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries (
request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL,
status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL
error_msg TEXT NOT NULL,
flagged INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -80,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings (
description TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
dismissed INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key)
);
@@ -114,6 +125,8 @@ func CountEntriesAt(path string) int {
}
defer conn.Close()
var n int
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
if err := conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
log.Printf("db: count entries: %v", err)
}
return n
}
+6 -2
View File
@@ -81,7 +81,11 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
return nil, err
}
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
var tsErr error
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
if tsErr != nil {
log.Printf("db: parse timestamp %q: %v", ts, tsErr)
}
e.Flagged = flagged != 0
entries = append(entries, e)
}
@@ -121,7 +125,7 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
func (d *DB) QueryEntries(where string) ([]Entry, error) {
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) // #nosec G202 -- intentional: user-supplied WHERE clause on a PRAGMA query_only=ON connection
rows, err := d.roConn.Query(q)
if err != nil {
return nil, err
+25 -2
View File
@@ -11,6 +11,7 @@ type Finding struct {
Title string
Description string
Severity string
Flagged bool
CreatedAt time.Time
}
@@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) {
rows, err := d.conn.Query(
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
`SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
)
if err != nil {
@@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
for rows.Next() {
var f Finding
var ts string
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil {
var flagged int
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil {
return nil, err
}
f.Flagged = flagged != 0
for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t.Local()
@@ -63,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
return err
}
func (d *DB) ToggleFindingFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
// DismissAllFindings dismisses unflagged findings first. If none are unflagged
// (only flagged ones remain), it dismisses everything.
func (d *DB) DismissAllFindings() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`)
return err
}
+36 -8
View File
@@ -1,6 +1,7 @@
package db
import (
"log"
"time"
)
@@ -16,16 +17,17 @@ type ReplayEntry struct {
ResponseRaw string
StatusCode int
ErrorMsg string
Flagged bool
}
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
res, err := d.conn.Exec(
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339),
e.Scheme, e.Host, e.Path, e.Method,
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
e.StatusCode, e.ErrorMsg,
e.StatusCode, e.ErrorMsg, e.Flagged,
)
if err != nil {
return 0, err
@@ -35,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
_, err := d.conn.Exec(
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID,
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID,
)
return err
}
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
rows, err := d.conn.Query(
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged
FROM replay_entries ORDER BY id ASC`,
)
if err != nil {
@@ -55,16 +57,27 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
for rows.Next() {
var e ReplayEntry
var ts string
var flagged int
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil {
return nil, err
}
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
var tsErr error
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
if tsErr != nil {
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
}
e.Flagged = flagged != 0
entries = append(entries, e)
}
return entries, rows.Err()
}
func (d *DB) ToggleReplayFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
func (d *DB) DeleteReplayEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
return err
@@ -74,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err
}
// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If
// none are unflagged (only flagged ones remain), it deletes everything.
func (d *DB) DeleteAllReplayEntriesExceptFlagged() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err
}
+3 -1
View File
@@ -48,6 +48,8 @@ func Init(cfg *config.Config) {
Flag: "󰈻 ",
}
} else {
I = &Icons{}
I = &Icons{
Flag: "*",
}
}
}
+3
View File
@@ -14,6 +14,9 @@ func FormatRawRequest(f *proxy.Flow) string {
r := f.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
if r.URL.Host != "" {
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
}
for _, line := range util.SortedHeaderLines(r.Header) {
sb.WriteString(line)
}
+7 -3
View File
@@ -6,15 +6,19 @@ import (
)
type FindingsKeyMap struct {
Dismiss key.Binding
Dismiss key.Binding
DismissAll key.Binding
Flag key.Binding
}
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
return FindingsKeyMap{
Dismiss: binding(cfg.Dismiss, "dismiss"),
Dismiss: binding(cfg.Dismiss, "dismiss"),
DismissAll: binding(cfg.DismissAll, "dismiss all"),
Flag: binding(cfg.Flag, "flag"),
}
}
func (f FindingsKeyMap) Bindings() []key.Binding {
return []key.Binding{f.Dismiss}
return []key.Binding{f.Flag, f.Dismiss, f.DismissAll}
}
+1 -1
View File
@@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyAs: binding(cfg.CopyAs, "copy as..."),
Copy: binding(cfg.Copy, "copy..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
Escape: binding(cfg.Escape, "cancel"),
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
+5 -1
View File
@@ -12,6 +12,8 @@ type ReplayKeyMap struct {
UndoEdits key.Binding
Delete key.Binding
DeleteAll key.Binding
Filter key.Binding
Flag key.Binding
}
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Delete: binding(cfg.Delete, "delete"),
DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"),
Flag: binding(cfg.Flag, "flag"),
}
}
func (r ReplayKeyMap) Bindings() []key.Binding {
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll}
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter}
}
+5 -5
View File
@@ -46,7 +46,7 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
msg := L.CheckString(1)
log.Printf("[plugin:%s] %s", p.Name, msg)
log.Printf("plugin %s: %s", p.Name, msg)
return 0
}))
@@ -85,7 +85,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
CreatedAt: time.Now(),
})
if err != nil {
log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
log.Printf("plugin %s: create_finding error: %v", p.Name, err)
return 0
}
_ = inserted
@@ -281,7 +281,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "sh", "-c", cmd)
c := exec.CommandContext(ctx, "sh", "-c", cmd) // #nosec G204 -- intentional: shell_pipe is a Lua plugin API for user-written scripts
c.Stdin = strings.NewReader(input)
var stdout, stderr bytes.Buffer
@@ -337,7 +337,7 @@ func luaTableString(t *lua.LTable, key string) string {
return ""
}
func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
func pushRequest(L *lua.LState, p *Plugin, f *goproxy.Flow) *lua.LTable {
t := L.NewTable()
r := f.Request
L.SetField(t, "method", lua.LString(r.Method))
@@ -377,7 +377,7 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
parsed, err := url.Parse(L.CheckString(2))
if err != nil {
log.Printf("[plugin] set_url: %v", err)
log.Printf("plugin %s: set_url: %v", p.Name, err)
return 0
}
r.URL = parsed
+4 -4
View File
@@ -395,25 +395,25 @@ func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lu
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f)}
return []lua.LValue{pushRequest(p.L, p, f)}
})
}
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f)}
return []lua.LValue{pushRequest(p.L, p, f)}
})
}
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
})
}
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
return []lua.LValue{pushRequest(p.L, f), pushResponse(p.L, f)}
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
})
}
+5 -2
View File
@@ -1,6 +1,7 @@
package plugins
import (
"log"
"os"
"path/filepath"
@@ -27,7 +28,7 @@ func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
path: path,
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
}
raw, err := os.ReadFile(path)
raw, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(dbDir, "plugins.yaml"), hardcoded filename
if os.IsNotExist(err) {
return pf, nil
}
@@ -76,7 +77,9 @@ func (pf *PluginsFile) register(id string, defaultEnabled bool, configText strin
}
}
pf.data.Plugins[id] = entry
_ = pf.save()
if err := pf.save(); err != nil {
log.Printf("plugin %s: register save: %v", id, err)
}
}
}
+1
View File
@@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
CaRootPath: caPath,
Upstream: cfg.UpstreamProxy,
SslInsecure: cfg.SslInsecure,
}
p, err := goproxy.NewProxy(opts)
-43
View File
@@ -1,43 +0,0 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
)
// PanelContentH returns the usable inner content height for a panel rendered by
// RenderWithTitle. It subtracts the two border lines (top + bottom) from the
// total panel height.
func PanelContentH(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a lipgloss bordered box with a title embedded in the
// top border, matching the border's own foreground color. height is the total
// desired output height (including both border lines).
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
label := " " + title + " "
fillW := boxWidth - lipgloss.Width(label) - 2
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+10 -80
View File
@@ -1,83 +1,23 @@
package style
import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
func ViewportView(vp *viewport.Model) string {
v := vp.View()
if vp.AtBottom() {
return v
}
lines := strings.Split(v, "\n")
if len(lines) == 0 {
return v
}
arrow := lipgloss.NewStyle().Foreground(S.Subtle).Render("↓")
arrowW := lipgloss.Width(arrow)
inner := vp.Width() - 2*arrowW
if inner < 0 {
inner = 0
}
lines[len(lines)-1] = arrow + strings.Repeat(" ", inner) + arrow
return strings.Join(lines, "\n")
}
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = S.PagerDotActive
p.InactiveDot = S.PagerDotInactive
return p
}
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts)
return ta
}
func SeverityStyle(sev string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true)
switch sev {
case "critical":
return base.Foreground(S.Error)
return base.Foreground(ilovetui.S.Error)
case "high":
return base.Foreground(S.Warning)
return base.Foreground(ilovetui.S.Warning)
case "medium":
return base.Foreground(S.Primary)
return base.Foreground(ilovetui.S.Primary)
case "low":
return base.Foreground(S.Success)
return base.Foreground(ilovetui.S.Success)
default:
return base.Foreground(S.Subtle)
return base.Foreground(ilovetui.S.Subtle)
}
}
@@ -85,22 +25,12 @@ func StatusStyle(code, width int) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(width)
switch {
case code >= 500:
return base.Foreground(S.Error)
return base.Foreground(ilovetui.S.Error)
case code >= 400:
return base.Foreground(S.Warning)
return base.Foreground(ilovetui.S.Warning)
case code >= 300:
return base.Foreground(S.Primary)
return base.Foreground(ilovetui.S.Primary)
default:
return base.Foreground(S.Success)
return base.Foreground(ilovetui.S.Success)
}
}
// SplitH splits totalHeight into top and bottom sections, accounting for the
// status bar height.
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
-236
View File
@@ -1,236 +0,0 @@
package style
import (
"github.com/anotherhadi/spilltea/internal/config"
"charm.land/glamour/v2/ansi"
)
func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig {
c := cfg.TUI.Colors
str := func(s string) *string { return &s }
hex := func(base string) *string { return str("#" + base) }
boolPtr := func(b bool) *bool { return &b }
uintPtr := func(u uint) *uint { return &u }
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
Color: hex(c.Base05),
},
Margin: uintPtr(2),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(c.Base03),
Italic: boolPtr(true),
},
Indent: uintPtr(1),
IndentToken: str("│ "),
},
List: ansi.StyleList{
LevelIndent: 2,
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(c.Base07),
BackgroundColor: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: hex(c.Base0C),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: hex(c.Base0B),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: hex(c.Base09),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: hex(c.Base08),
Bold: boolPtr(false),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
HorizontalRule: ansi.StylePrimitive{
Color: hex(c.Base03),
Format: "\n--------\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: hex(c.Base0C),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: hex(c.Base0C),
Underline: boolPtr(true),
},
ImageText: ansi.StylePrimitive{
Color: hex(c.Base04),
Format: "Image: {{.text}} ->",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(c.Base0B),
BackgroundColor: hex(c.Base01),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(c.Base04),
},
Margin: uintPtr(2),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Error: ansi.StylePrimitive{
Color: hex(c.Base07),
BackgroundColor: hex(c.Base08),
},
Comment: ansi.StylePrimitive{
Color: hex(c.Base03),
Italic: boolPtr(true),
},
CommentPreproc: ansi.StylePrimitive{
Color: hex(c.Base09),
},
Keyword: ansi.StylePrimitive{
Color: hex(c.Base0E),
},
KeywordReserved: ansi.StylePrimitive{
Color: hex(c.Base0E),
},
KeywordNamespace: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
KeywordType: ansi.StylePrimitive{
Color: hex(c.Base0A),
},
Operator: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Punctuation: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Name: ansi.StylePrimitive{
Color: hex(c.Base05),
},
NameBuiltin: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
NameTag: ansi.StylePrimitive{
Color: hex(c.Base08),
},
NameAttribute: ansi.StylePrimitive{
Color: hex(c.Base0A),
},
NameClass: ansi.StylePrimitive{
Color: hex(c.Base0A),
Bold: boolPtr(true),
Underline: boolPtr(true),
},
NameConstant: ansi.StylePrimitive{
Color: hex(c.Base09),
},
NameDecorator: ansi.StylePrimitive{
Color: hex(c.Base0C),
},
NameFunction: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
LiteralNumber: ansi.StylePrimitive{
Color: hex(c.Base09),
},
LiteralString: ansi.StylePrimitive{
Color: hex(c.Base0B),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: hex(c.Base0C),
},
GenericDeleted: ansi.StylePrimitive{
Color: hex(c.Base08),
},
GenericEmph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
Color: hex(c.Base0B),
},
GenericStrong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: hex(c.Base04),
},
Background: ansi.StylePrimitive{
BackgroundColor: hex(c.Base01),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
},
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n> ",
},
}
}
+26 -25
View File
@@ -3,12 +3,13 @@ package style
import (
"bytes"
"encoding/json"
"image/color"
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/config"
"golang.org/x/net/html"
"image/color"
)
func Paint(c color.Color, s string) string {
@@ -75,8 +76,8 @@ func highlightHeaders(raw string) string {
} else if trimmed == "" {
out.WriteString(line)
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
out.WriteString(Paint(S.Text, trimmed[idx+2:]))
out.WriteString(Paint(ilovetui.S.Subtle, trimmed[:idx+2]))
out.WriteString(Paint(ilovetui.S.Text, trimmed[idx+2:]))
} else {
out.WriteString(line)
}
@@ -95,16 +96,16 @@ func highlightStatusLine(line string) string {
switch parts[0] {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
result += Paint(S.Primary, parts[1])
result += Paint(ilovetui.S.Primary, parts[1])
if len(parts) == 3 {
result += " " + Paint(S.Subtle, parts[2])
result += " " + Paint(ilovetui.S.Subtle, parts[2])
}
return result
}
result := Paint(S.Subtle, parts[0]) + " "
result += Paint(S.Warning, parts[1])
result := Paint(ilovetui.S.Subtle, parts[0]) + " "
result += Paint(ilovetui.S.Warning, parts[1])
if len(parts) == 3 {
result += " " + Paint(S.MutedFg, parts[2])
result += " " + Paint(ilovetui.S.Muted, parts[2])
}
return result
}
@@ -134,9 +135,9 @@ func highlightJSON(s string) string {
k++
}
if k < n && s[k] == ':' {
out.WriteString(Paint(S.Primary, str))
out.WriteString(Paint(ilovetui.S.Primary, str))
} else {
out.WriteString(Paint(S.Success, str))
out.WriteString(Paint(ilovetui.S.Success, str))
}
i = j
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
@@ -147,19 +148,19 @@ func highlightJSON(s string) string {
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
j++
}
out.WriteString(Paint(S.Warning, s[i:j]))
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
i = j
case i+4 <= n && s[i:i+4] == "true":
out.WriteString(Paint(S.Error, "true"))
out.WriteString(Paint(ilovetui.S.Error, "true"))
i += 4
case i+5 <= n && s[i:i+5] == "false":
out.WriteString(Paint(S.Error, "false"))
out.WriteString(Paint(ilovetui.S.Error, "false"))
i += 5
case i+4 <= n && s[i:i+4] == "null":
out.WriteString(Paint(S.Error, "null"))
out.WriteString(Paint(ilovetui.S.Error, "null"))
i += 4
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
out.WriteString(Paint(S.Subtle, string(ch)))
out.WriteString(Paint(ilovetui.S.Subtle, string(ch)))
i++
default:
out.WriteByte(ch)
@@ -265,11 +266,11 @@ func highlightHTML(s string) string {
if i+4 <= n && s[i:i+4] == "<!--" {
end := strings.Index(s[i:], "-->")
if end == -1 {
out.WriteString(Paint(S.Subtle, s[i:]))
out.WriteString(Paint(ilovetui.S.Subtle, s[i:]))
break
}
end = i + end + 3
out.WriteString(Paint(S.Subtle, s[i:end]))
out.WriteString(Paint(ilovetui.S.Subtle, s[i:end]))
i = end
continue
}
@@ -278,10 +279,10 @@ func highlightHTML(s string) string {
i++
continue
}
out.WriteString(Paint(S.Subtle, "<"))
out.WriteString(Paint(ilovetui.S.Subtle, "<"))
i++
if i < n && (s[i] == '/' || s[i] == '!') {
out.WriteString(Paint(S.Subtle, string(s[i])))
out.WriteString(Paint(ilovetui.S.Subtle, string(s[i])))
i++
}
j := i
@@ -289,7 +290,7 @@ func highlightHTML(s string) string {
j++
}
if j > i {
out.WriteString(Paint(S.Primary, s[i:j]))
out.WriteString(Paint(ilovetui.S.Primary, s[i:j]))
i = j
}
for i < n && s[i] != '>' {
@@ -299,10 +300,10 @@ func highlightHTML(s string) string {
out.WriteByte(ch)
i++
case ch == '/':
out.WriteString(Paint(S.Subtle, "/"))
out.WriteString(Paint(ilovetui.S.Subtle, "/"))
i++
case ch == '=':
out.WriteString(Paint(S.Subtle, "="))
out.WriteString(Paint(ilovetui.S.Subtle, "="))
i++
case ch == '"' || ch == '\'':
q := ch
@@ -313,19 +314,19 @@ func highlightHTML(s string) string {
if j < n {
j++
}
out.WriteString(Paint(S.Success, s[i:j]))
out.WriteString(Paint(ilovetui.S.Success, s[i:j]))
i = j
default:
j = i
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
j++
}
out.WriteString(Paint(S.Warning, s[i:j]))
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
i = j
}
}
if i < n && s[i] == '>' {
out.WriteString(Paint(S.Subtle, ">"))
out.WriteString(Paint(ilovetui.S.Subtle, ">"))
i++
}
}
+8 -78
View File
@@ -1,34 +1,13 @@
package style
import (
"image/color"
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
ilovetui "github.com/anotherhadi/ilovetui"
)
type Styles struct {
Primary color.Color
Success color.Color
Error color.Color
Warning color.Color
SubtleBg color.Color
Selection color.Color
Text color.Color
MutedFg color.Color
Subtle color.Color
Bold lipgloss.Style
Faint lipgloss.Style
Panel lipgloss.Style
PanelFocused lipgloss.Style
PanelEditing lipgloss.Style
PagerDotActive string
PagerDotInactive string
methodGet lipgloss.Style
methodPost lipgloss.Style
methodPutPatch lipgloss.Style
@@ -38,70 +17,21 @@ type Styles struct {
var S *Styles
func Init(cfg *config.Config) {
c := cfg.TUI.Colors
subtleBg := lipgloss.Color("#" + c.Base01) // Lighter Background (status bars)
selection := lipgloss.Color("#" + c.Base02) // Selection Background
subtle := lipgloss.Color("#" + c.Base03) // Faint text, borders
mutedFg := lipgloss.Color("#" + c.Base04) // Muted foreground
text := lipgloss.Color("#" + c.Base05) // Default Foreground
errCol := lipgloss.Color("#" + c.Base08) // Red: errors
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
func Init() {
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
S = &Styles{
Primary: primary,
Success: success,
Error: errCol,
Warning: warning,
SubtleBg: subtleBg,
Selection: selection,
MutedFg: mutedFg,
Text: text,
Subtle: subtle,
Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(subtle).Faint(true),
Panel: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(subtle),
PanelFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primary),
PanelEditing: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple),
BorderForeground(ilovetui.S.Base0E),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
methodGet: methodBase.Foreground(success),
methodPost: methodBase.Foreground(warning),
methodPutPatch: methodBase.Foreground(primary),
methodDelete: methodBase.Foreground(errCol),
methodDefault: methodBase.Foreground(text),
methodGet: methodBase.Foreground(ilovetui.S.Success),
methodPost: methodBase.Foreground(ilovetui.S.Warning),
methodPutPatch: methodBase.Foreground(ilovetui.S.Primary),
methodDelete: methodBase.Foreground(ilovetui.S.Error),
methodDefault: methodBase.Foreground(ilovetui.S.Text),
}
}
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
return h
}
func (s *Styles) Method(method string) lipgloss.Style {
switch method {
case "GET":
+23 -9
View File
@@ -1,7 +1,6 @@
package app
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -37,11 +36,9 @@ func tickCmd() tea.Cmd {
})
}
var sidebarEntries = pageRegistry
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(sidebarEntries))
for i, e := range sidebarEntries {
m := make(map[string]page, len(pageRegistry))
for i, e := range pageRegistry {
m[strconv.Itoa(i+1)] = e.id
}
return m
@@ -55,6 +52,8 @@ type Model struct {
database *db.DB
logFile *os.File
pluginManager *plugins.Manager
fatalErr error
logFileErr error
width int
height int
@@ -96,8 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model {
d, err := db.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "db: %v\n", err)
os.Exit(1)
m.fatalErr = err
return m
}
m.database = d
broker.SetDB(d)
@@ -130,14 +129,18 @@ func New(broker *intercept.Broker, name, path string) Model {
m.logFile = lf
log.SetOutput(lf)
logrus.SetOutput(lf)
} else {
m.logFileErr = err
}
return m
}
func (m Model) FatalErr() error { return m.fatalErr }
func (m Model) Init() tea.Cmd {
mgr := m.pluginManager
return tea.Batch(
cmds := []tea.Cmd{
intercept.WaitForRequest(m.broker),
intercept.WaitForResponse(m.broker),
tickCmd(),
@@ -146,5 +149,16 @@ func (m Model) Init() tea.Cmd {
plugins.WaitForQuit(mgr),
findingsUI.RefreshCmd(m.database),
func() tea.Msg { mgr.RunOnStart(); return nil },
)
}
if m.logFileErr != nil {
err := m.logFileErr
cmds = append(cmds, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Warning",
Body: "Could not open log file: " + err.Error(),
Kind: notificationsUI.KindWarning,
}
})
}
return tea.Batch(cmds...)
}
+11 -12
View File
@@ -5,7 +5,7 @@ import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
)
type sidebarState string
@@ -42,7 +42,6 @@ func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarHidden {
return ""
}
s := style.S
// content width inside bordered panel
inner := m.getSidebarWidth() - 2
@@ -50,20 +49,20 @@ func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarCollapsed {
titleText = "SPLT"
}
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 1).Render(titleText)
divider := strings.Repeat("─", inner)
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
textNormal := lipgloss.NewStyle().Foreground(s.Text)
badgeSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Bold(true)
badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(s.Warning).Bold(true)
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
for i, entry := range sidebarEntries {
for i, entry := range pageRegistry {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
@@ -95,13 +94,13 @@ func (m *Model) renderSidebar() string {
}
parts := []string{
title,
lipgloss.NewStyle().Width(inner).Foreground(s.Subtle).Padding(0, 1).Render(name),
lipgloss.NewStyle().Width(inner).Foreground(ilovetui.S.Subtle).Padding(0, 1).Render(name),
}
parts = append(parts,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider),
items.String(),
)
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
return ilovetui.S.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
}
+4 -21
View File
@@ -2,7 +2,6 @@ package app
import (
"log"
"os"
"os/exec"
"path/filepath"
@@ -21,6 +20,7 @@ import (
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -107,16 +107,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case proxyPkg.ErrMsg:
if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err)
return m, tea.Batch(
func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Proxy Error",
Body: msg.Err.Error(),
Kind: notificationsUI.KindError,
}
},
tea.Quit,
)
m.fatalErr = msg.Err
return m, tea.Quit
}
return m, nil
@@ -170,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.KeyPressMsg:
// ctrl+c always quits, even when a textarea is focused.
if msg.String() == "ctrl+c" {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path
}
if !m.activeIsEditing() {
+13 -11
View File
@@ -3,28 +3,31 @@ package app
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
)
func (m Model) newView(content string) tea.View {
v := tea.NewView(content)
v.AltScreen = true
v.WindowTitle = "Spilltea: " + m.projectName
return v
}
func (m Model) View() tea.View {
if m.width == 0 {
v := tea.NewView("")
v.AltScreen = true
return v
return m.newView("")
}
normal := m.renderNormal()
if m.copyAs.IsOpen() {
v := tea.NewView(m.copyAs.View(normal))
v.AltScreen = true
v := m.newView(m.copyAs.View(normal))
v.MouseMode = tea.MouseModeCellMotion
return v
}
if m.copy.IsOpen() {
v := tea.NewView(m.copy.View(normal))
v.AltScreen = true
v := m.newView(m.copy.View(normal))
v.MouseMode = tea.MouseModeCellMotion
return v
}
@@ -34,8 +37,7 @@ func (m Model) View() tea.View {
rendered = m.notifications.View(normal)
}
v := tea.NewView(rendered)
v.AltScreen = true
v := m.newView(rendered)
v.MouseMode = tea.MouseModeCellMotion
return v
}
@@ -52,5 +54,5 @@ func (m *Model) renderActivePage() string {
return e.render(m)
}
}
return style.S.Faint.Render("Work in progress")
return ilovetui.S.Faint.Render("Work in progress")
}
+8 -10
View File
@@ -6,7 +6,7 @@ import (
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
)
const (
@@ -47,20 +47,18 @@ type Model struct {
}
func New() Model {
s := style.S
delegate := list.NewDefaultDelegate()
delegate.SetSpacing(0)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(1)
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1)
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Muted).PaddingLeft(1)
l := list.New(allItems, delegate, popupW, 8)
l.SetShowTitle(false)
@@ -125,7 +123,7 @@ func (m Model) popupHeight() int {
}
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
return ilovetui.ContentHeight(m.popupHeight()) - 1
}
func (m Model) extract(id string) string {
+4 -6
View File
@@ -2,14 +2,12 @@ package copy
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
)
func (m *Model) View(background string) string {
s := style.S
hint := lipgloss.NewStyle().Foreground(s.Subtle).
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
Render(" enter: copy • /: filter • esc: cancel")
inner := lipgloss.JoinVertical(lipgloss.Left,
@@ -19,10 +17,10 @@ func (m *Model) View(background string) string {
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary)
BorderForeground(ilovetui.S.Primary)
popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy", inner, m.popupInnerWidth()+2, popupH)
popup := ilovetui.RenderWithTitle(border, "Copy", inner, m.popupInnerWidth()+2, popupH)
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
}
+1 -1
View File
@@ -16,7 +16,7 @@ type parsedRequest struct {
path string
host string
scheme string
headers []header // garder header{key, value} pour compat locale
headers []header
body string
}
+8 -10
View File
@@ -4,7 +4,7 @@ import (
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
)
const (
@@ -48,20 +48,18 @@ type Model struct {
}
func New() Model {
s := style.S
delegate := list.NewDefaultDelegate()
delegate.SetSpacing(0)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(1)
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1)
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Muted).PaddingLeft(1)
l := list.New(allFormats, delegate, popupW, 8)
l.SetShowTitle(false)
@@ -116,5 +114,5 @@ func (m Model) popupHeight() int {
// listHeight = panel content area - hint line (1)
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
return ilovetui.ContentHeight(m.popupHeight()) - 1
}
+5 -9
View File
@@ -4,14 +4,12 @@ import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/charmbracelet/x/ansi"
)
func (m *Model) View(background string) string {
s := style.S
hint := lipgloss.NewStyle().Foreground(s.Subtle).
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
Render(" enter: copy • /: filter • esc: cancel")
inner := lipgloss.JoinVertical(lipgloss.Left,
@@ -21,17 +19,15 @@ func (m *Model) View(background string) string {
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary)
BorderForeground(ilovetui.S.Primary)
popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, m.popupInnerWidth()+2, popupH)
popup := ilovetui.RenderWithTitle(border, "Copy as", inner, m.popupInnerWidth()+2, popupH)
return OverlayCenter(background, popup, m.width, m.height)
}
func OverlayCenter(bg, popup string, w, h int) string {
s := style.S
stripped := ansi.Strip(bg)
rawLines := strings.Split(stripped, "\n")
bgRunes := make([][]rune, h)
@@ -67,7 +63,7 @@ func OverlayCenter(bg, popup string, w, h int) string {
startX = 0
}
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true)
result := make([]string, h)
for y := 0; y < h; y++ {
@@ -7,7 +7,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/charmbracelet/x/ansi"
)
@@ -76,7 +76,6 @@ func (m Model) View(background string) string {
return background
}
s := style.S
const popupW = 34
var popups []string
@@ -89,17 +88,17 @@ func (m Model) View(background string) string {
var accent color.Color
switch n.kind {
case KindSuccess:
accent = s.Success
accent = ilovetui.S.Success
case KindWarning:
accent = s.Warning
accent = ilovetui.S.Warning
case KindError:
accent = s.Error
accent = ilovetui.S.Error
default:
accent = s.Primary
accent = ilovetui.S.Primary
}
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
bodyStr := lipgloss.NewStyle().Foreground(s.Text).Width(popupW).Render(n.body)
bodyStr := lipgloss.NewStyle().Foreground(ilovetui.S.Text).Width(popupW).Render(n.body)
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
box := lipgloss.NewStyle().
+10 -12
View File
@@ -8,6 +8,7 @@ import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
@@ -85,10 +86,9 @@ func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
segs[lo], segs[hi] = segs[hi], segs[lo]
}
s := style.S
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
dim := lipgloss.NewStyle().Foreground(s.Subtle)
boldErr := lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true)
boldOk := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true)
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
var lb, rb strings.Builder
for _, seg := range segs {
@@ -212,9 +212,9 @@ type Model struct {
func New() Model {
return Model{
leftViewport: style.NewViewport(),
rightViewport: style.NewViewport(),
help: style.NewHelp(),
leftViewport: ilovetui.NewViewport(),
rightViewport: ilovetui.NewViewport(),
help: ilovetui.NewHelp(),
}
}
@@ -255,7 +255,7 @@ func (m *Model) recalcSizes() {
rightInner = 0
}
viewportH := style.PanelContentH(panelH)
viewportH := ilovetui.ContentHeight(panelH)
m.leftViewport.SetWidth(leftInner)
m.leftViewport.SetHeight(viewportH)
@@ -296,13 +296,11 @@ func hlLines(raw string) []string {
}
func (m *Model) refreshViewports() {
s := style.S
if m.left.raw == "" {
placeholder := lipgloss.Place(
m.leftViewport.Width(), m.leftViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
ilovetui.S.Faint.Render(util.EmptyState(m.leftViewport.Width(), "<(^_^)>", "send two entries here to compare")),
)
m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("")
@@ -314,7 +312,7 @@ func (m *Model) refreshViewports() {
placeholder := lipgloss.Place(
m.rightViewport.Width(), m.rightViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
ilovetui.S.Faint.Render(util.EmptyState(m.rightViewport.Width(), "(・3・)", "waiting for second entry…")),
)
m.rightViewport.SetContent(placeholder)
return
+11 -14
View File
@@ -5,6 +5,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
@@ -26,8 +27,6 @@ func (m Model) View() tea.View {
}
func (m *Model) renderPanels(panelH int) string {
s := style.S
leftW := m.width / 2
rightW := m.width - leftW
@@ -46,20 +45,20 @@ func (m *Model) renderPanels(panelH int) string {
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
}
leftBorder := s.Panel
rightBorder := s.Panel
leftBorder := ilovetui.S.Panel
rightBorder := ilovetui.S.Panel
switch m.focus {
case bothSlots:
leftBorder = s.PanelFocused
rightBorder = s.PanelFocused
leftBorder = ilovetui.S.PanelFocused
rightBorder = ilovetui.S.PanelFocused
case leftSlot:
leftBorder = s.PanelFocused
leftBorder = ilovetui.S.PanelFocused
case rightSlot:
rightBorder = s.PanelFocused
rightBorder = ilovetui.S.PanelFocused
}
left := style.RenderWithTitle(leftBorder, leftTitle, style.ViewportView(&m.leftViewport), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, style.ViewportView(&m.rightViewport), rightW, panelH)
left := ilovetui.RenderWithTitle(leftBorder, leftTitle, ilovetui.ViewportView(&m.leftViewport), leftW, panelH)
right := ilovetui.RenderWithTitle(rightBorder, rightTitle, ilovetui.ViewportView(&m.rightViewport), rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
@@ -69,12 +68,11 @@ func (m *Model) renderStatusBar() string {
}
func renderLeftLines(lines []diffLine) string {
s := style.S
var sb strings.Builder
for _, l := range lines {
switch l.kind {
case lineRemoved:
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
sb.WriteString(style.Paint(ilovetui.S.Error, "- ") + l.text + "\n")
case lineAdded:
sb.WriteString("\n")
default:
@@ -85,12 +83,11 @@ func renderLeftLines(lines []diffLine) string {
}
func renderRightLines(lines []diffLine) string {
s := style.S
var sb strings.Builder
for _, l := range lines {
switch l.kind {
case lineAdded:
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
sb.WriteString(style.Paint(ilovetui.S.Success, "+ ") + l.text + "\n")
case lineRemoved:
sb.WriteString("\n")
default:
+7 -7
View File
@@ -14,8 +14,8 @@ import (
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func readDoc(name string) string {
@@ -25,10 +25,10 @@ func readDoc(name string) string {
var contentMarkdown = strings.Join([]string{
readDoc("main.md"),
readDoc("legal-disclaimer.md"),
readDoc("basics.md"),
readDoc("proxy.md"),
readDoc("certificate.md"),
readDoc("legal-disclaimer.md"),
readDoc("basics.md"),
readDoc("history.md"),
}, "\n")
@@ -58,12 +58,12 @@ func New() Model {
ti := textinput.New()
ti.Prompt = "/"
s := ti.Styles()
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
ti.SetStyles(s)
return Model{
viewport: viewport.New(),
help: style.NewHelp(),
help: ilovetui.NewHelp(),
searchInput: ti,
}
}
@@ -182,8 +182,8 @@ func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int)
return ansiLine
}
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg))
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text))
type injection struct {
visPos int
+1 -4
View File
@@ -18,12 +18,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if e.searching {
switch {
case key.Matches(msg, d.SearchReset):
case key.Matches(msg, g.Escape):
e.searching = false
e.searchInput.Blur()
e.searchInput.SetValue("")
e.matches = nil
e.matchIndex = 0
e.SetSize(e.width, e.height)
case msg.String() == "enter":
e.searching = false
+4 -4
View File
@@ -9,15 +9,15 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
func windowStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(style.S.Subtle).
BorderForeground(ilovetui.S.Subtle).
Padding(0, 0)
}
@@ -31,7 +31,7 @@ func (e Model) View() tea.View {
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
}
count := lipgloss.NewStyle().Padding(0, 1).
Foreground(style.S.MutedFg).
Foreground(ilovetui.S.Muted).
Render(countText)
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
}
@@ -61,7 +61,7 @@ func (m *Model) renderMarkdown() {
width := m.viewport.Width() - 2
renderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
glamour.WithWordWrap(width),
)
+11 -12
View File
@@ -11,10 +11,9 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
@@ -39,10 +38,10 @@ type Model struct {
func New() Model {
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
listViewport: ilovetui.NewViewport(),
bodyViewport: ilovetui.NewViewport(),
pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(),
}
}
@@ -76,9 +75,9 @@ func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
inner := m.width - 2
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
@@ -89,7 +88,7 @@ func (m *Model) recalcSizes() {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
bodyVH := ilovetui.ContentHeight(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
@@ -148,7 +147,7 @@ func (m *Model) refreshBodyScroll(reset bool) {
func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" {
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
return ilovetui.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
}
tmpl, err := template.New("").Parse(src)
if err != nil {
@@ -164,7 +163,7 @@ func (m *Model) renderMarkdownCached(src string, width int) string {
// Rebuild renderer if width changed or not yet built.
if m.renderer == nil || m.rendererWidth != width {
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
glamour.WithWordWrap(width),
)
if err == nil {
@@ -187,7 +186,7 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help}
return []key.Binding{g.Up, g.Down, f.Flag, f.Dismiss, f.DismissAll, g.Copy, g.Help}
}
func (m findingsKeyMap) FullHelp() [][]key.Binding {
+16
View File
@@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, f.Flag):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil {
log.Printf("findings: toggle flag: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
@@ -80,6 +88,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, f.DismissAll):
if m.database != nil {
if err := m.database.DismissAllFindings(); err != nil {
log.Printf("findings: dismiss all: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp):
util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown):
+26 -17
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
@@ -27,34 +28,31 @@ func (m Model) View() tea.View {
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
var dots string
if len(m.findings) > 0 {
dots = s.Faint.Render(m.pager.View())
dots = ilovetui.S.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Findings+"Findings", inner, w, h)
return ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, icons.I.Findings+"Findings", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
title := "Description"
if len(m.findings) > 0 {
title = m.findings[m.cursor].Title
}
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
}
func (m *Model) renderList() string {
s := style.S
if len(m.findings) == 0 {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(҂◡_◡) ᕤ", "no findings")),
)
}
@@ -68,39 +66,50 @@ func (m *Model) renderList() string {
sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05")
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW
if titleW < 0 {
titleW = 0
}
pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
pluginStr := ilovetui.S.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(titleW).Render(f.Title),
)
} else {
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
flagSt.Width(2).Render(flagStr),
sevLabel,
" ",
pluginStr,
" ",
s.Faint.Width(10).Render(ts),
ilovetui.S.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(f.Title),
ilovetui.S.Bold.Render(f.Title),
)
}
sb.WriteString(fmt.Sprintf("%s\n", line))
+8 -8
View File
@@ -7,9 +7,9 @@ import (
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
@@ -44,10 +44,10 @@ func New() Model {
ti := textinput.New()
ti.Prompt = ""
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
listViewport: ilovetui.NewViewport(),
bodyViewport: ilovetui.NewViewport(),
pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(),
searchInput: ti,
}
}
@@ -124,14 +124,14 @@ func (m *Model) recalcSizes() {
// 2 (padding) + 2 (prefix char + space)
m.searchInput.SetWidth(m.width - 4)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
inner := m.width - 2
if inner < 0 {
inner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
@@ -142,7 +142,7 @@ func (m *Model) recalcSizes() {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
bodyVH := ilovetui.ContentHeight(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
+35 -27
View File
@@ -2,11 +2,13 @@ package history
import (
"fmt"
"log"
"net/http"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
@@ -24,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
if database == nil {
return EntriesLoadedMsg{}
}
entries, _ := database.ListEntries()
entries, err := database.ListEntries()
if err != nil {
log.Printf("history: load entries: %v", err)
}
return EntriesLoadedMsg{Entries: entries}
}
}
@@ -71,14 +76,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case SearchResultMsg:
var selectedID int64
if m.cursor >= 0 && m.cursor < len(m.entries) {
selectedID = m.entries[m.cursor].ID
}
m.entries = msg.Entries
m.cursor = 0
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
break
}
}
}
m.searchErr = ""
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
if selectedID == 0 {
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
if m.searchKind == searchKindSQL {
m.acceptSearch()
}
@@ -100,7 +119,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
if m.searchKind != searchKindOff && !m.searchAccepted {
// Actively typing: only search navigation + accept/cancel.
switch {
case key.Matches(msg, g.Escape):
return m, m.clearSearch()
@@ -111,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.acceptSearch()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
default:
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
@@ -233,7 +233,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, h.Flag):
if len(m.entries) > 0 && m.database != nil {
m.database.ToggleFlag(m.entries[m.cursor].ID)
if err := m.database.ToggleFlag(m.entries[m.cursor].ID); err != nil {
log.Printf("history: toggle flag: %v", err)
}
return m, m.RefreshCmd()
}
@@ -241,7 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(m.entries) > 0 {
id := m.entries[m.cursor].ID
if m.database != nil {
m.database.DeleteEntry(id)
if err := m.database.DeleteEntry(id); err != nil {
log.Printf("history: delete entry: %v", err)
}
}
return m, LoadEntriesCmd(m.database)
}
@@ -260,10 +264,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if hasUnflagged && e.Flagged {
continue
}
m.database.DeleteEntry(e.ID)
if err := m.database.DeleteEntry(e.ID); err != nil {
log.Printf("history: delete entry: %v", err)
}
}
} else {
m.database.DeleteAllExceptFlagged()
if err := m.database.DeleteAllExceptFlagged(); err != nil {
log.Printf("history: delete all unflagged: %v", err)
}
}
}
return m, m.clearSearch()
@@ -345,7 +353,7 @@ func (m *Model) refreshBody() {
}
if raw == "" {
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(w, "(˘・_・˘)", "no response stored"))))
return
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
+25 -34
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
@@ -17,7 +18,7 @@ func (m Model) View() tea.View {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
@@ -28,71 +29,67 @@ func (m Model) View() tea.View {
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
var dots string
if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View())
dots = ilovetui.S.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.History+"History", inner, w, h)
return ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, icons.I.History+"History", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
title := icons.I.Request + "Request"
if m.focusedPanel == panelResponse {
title = icons.I.Response + "Response"
}
return style.RenderWithTitle(s.Panel, title, style.ViewportView(&m.bodyViewport), m.width, h)
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
}
func (m *Model) renderStatusBar() string {
s := style.S
pad := lipgloss.NewStyle().Padding(0, 1)
escKey := keys.Keys.Global.Escape.Help().Key
switch m.searchKind {
case searchKindFulltext:
filterKey := keys.Keys.History.Filter.Help().Key
if m.searchAccepted {
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.searchInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
}
return pad.Render(s.Faint.Render(filterKey) + " " + m.searchInput.View())
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.searchInput.View())
case searchKindSQL:
sqlKey := keys.Keys.History.SqlQuery.Help().Key
if m.searchAccepted {
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(sqlKey) + " " + ilovetui.S.Bold.Render(m.searchInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
}
return pad.Render(s.Faint.Render(sqlKey) + " " + m.searchInput.View())
return pad.Render(ilovetui.S.Faint.Render(sqlKey) + " " + m.searchInput.View())
default:
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
}
}
func (m *Model) renderList() string {
s := style.S
if m.searchErr != "" {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.NewStyle().Foreground(s.Error).Render(m.searchErr),
lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render(m.searchErr),
)
}
if len(m.entries) == 0 {
msg := util.CenterLines("(⌐■_■)", "no history yet")
msg := util.EmptyState(m.listViewport.Width(), "(⌐■_■)", "no history yet")
if m.searchKind != searchKindOff {
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
msg = util.EmptyState(m.listViewport.Width(), "ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
ilovetui.S.Faint.Render(msg),
)
}
@@ -103,7 +100,7 @@ func (m *Model) renderList() string {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
selBg := ilovetui.S.Selection
w := m.listViewport.Width()
statusStr := fmt.Sprintf("%3d", e.StatusCode)
@@ -115,7 +112,7 @@ func (m *Model) renderList() string {
ts := e.Timestamp.Format("15:04:05")
statusSt := style.StatusStyle(e.StatusCode, 3)
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string
if selected {
@@ -123,18 +120,15 @@ func (m *Model) renderList() string {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
bg.Foreground(s.Primary).Width(2).Render(flagStr),
s.Method(e.Method).Background(selBg).Render(e.Method),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
style.S.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
@@ -142,21 +136,18 @@ func (m *Model) renderList() string {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
flagSt.Width(2).Render(flagStr),
s.Method(e.Method).Render(e.Method),
style.S.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Faint.Width(10).Render(ts),
ilovetui.S.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
ilovetui.S.Bold.Render(e.Host),
ilovetui.S.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
+13 -14
View File
@@ -14,10 +14,10 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
@@ -88,19 +88,18 @@ type homeDelegate struct {
}
func newHomeDelegate() homeDelegate {
s := style.S
leftBorder := lipgloss.Border{Left: "│"}
return homeDelegate{
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
normalTitle: lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(4),
normalDesc: lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true).PaddingLeft(4),
selectedTitle: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(3),
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(3),
selectedDesc: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(3),
BorderForeground(ilovetui.S.Primary).
Foreground(ilovetui.S.Muted).PaddingLeft(3),
filterMatch: lipgloss.NewStyle().Underline(true),
}
}
@@ -304,14 +303,13 @@ func buildItems(projects []Project) []list.Item {
}
func (m Model) renderHelpLine() string {
s := style.S
k := keys.Keys.Home
fs := m.list.FilterState()
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
dStyle := s.Faint.Inline(true)
kStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Inline(true)
dStyle := ilovetui.S.Faint.Inline(true)
sep := s.Faint.Inline(true).Render(" • ")
sep := ilovetui.S.Faint.Inline(true).Render(" • ")
item := func(keyStr, desc string) string {
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
}
@@ -320,13 +318,14 @@ func (m Model) renderHelpLine() string {
}
var parts []string
escKey := keys.Keys.Global.Escape.Help().Key
if fs == list.Filtering {
parts = append(parts, item("enter", "apply filter"))
parts = append(parts, item("esc", "cancel"))
parts = append(parts, item(escKey, "cancel"))
} else {
parts = append(parts, item("↑/↓", "navigate"))
if fs == list.FilterApplied {
parts = append(parts, item("esc", "clear filter"))
parts = append(parts, item(escKey, "clear filter"))
} else {
parts = append(parts, binding(k.Filter))
}
+14 -7
View File
@@ -3,6 +3,7 @@ package home
import (
crypto "crypto/rand"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -72,7 +73,8 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
return m, m.nameInput.Focus()
case kindTemp:
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
log.Printf("home: create temp dir: %v", err)
return m, nil
}
initProjectFiles(dir)
@@ -90,7 +92,9 @@ func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
return m, nil
}
dir := filepath.Dir(item.path) // parent dir of data.db
os.RemoveAll(dir)
if err := os.RemoveAll(dir); err != nil {
log.Printf("home: remove project dir: %v", err)
}
idx := m.list.GlobalIndex()
m.list.RemoveItem(idx)
if idx > 0 && idx >= len(m.list.Items()) {
@@ -113,7 +117,8 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.mode = modeSelect
m.nameInput.Blur()
dir := filepath.Join(m.projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
log.Printf("home: create project dir %q: %v", name, err)
return m, nil
}
initProjectFiles(dir)
@@ -147,14 +152,14 @@ func IsValidProjectName(s string) bool {
func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" || name == "temp" || name == "temporary" {
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
}
dir := filepath.Join(projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
initProjectFiles(dir)
@@ -171,9 +176,11 @@ func initProjectFiles(dir string) {
for _, name := range []string{"data.db", "logs.log"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
f, err := os.Create(p)
f, err := os.Create(p) // #nosec G304 -- p is filepath.Join(dir, hardcoded_name), no traversal possible
if err == nil {
f.Close()
if cerr := f.Close(); cerr != nil {
log.Printf("home: close project file: %v", cerr)
}
}
}
}
+10 -9
View File
@@ -5,29 +5,29 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
const inputPanelMaxW = 44
func (m Model) View() tea.View {
s := style.S
iw := m.innerW()
var sb strings.Builder
sb.WriteString("\n")
if m.height > teapotMinH {
frames := teapot.TeapotFrames()
frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame])
frame := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render(frames[m.teapotFrame])
sb.WriteString(center(iw, frame))
sb.WriteString("\n\n")
} else {
sb.WriteString("\n")
}
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA")))
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Render("SPILLTEA")))
sb.WriteString("\n")
sb.WriteString(center(iw, s.Faint.Render("choose a project to get started")))
sb.WriteString(center(iw, ilovetui.S.Faint.Render("choose a project to get started")))
sb.WriteString("\n\n")
if m.mode == modeNaming {
@@ -46,11 +46,11 @@ func (m Model) View() tea.View {
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.WindowTitle = "Spilltea"
return v
}
func (m Model) renderNamingPanel() string {
s := style.S
iw := m.innerW()
panelW := inputPanelMaxW
@@ -63,15 +63,16 @@ func (m Model) renderNamingPanel() string {
innerW := inputPanelInnerW(iw)
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name")
label := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Render("Project name")
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary).
BorderForeground(ilovetui.S.Primary).
Padding(1, 2).
Width(panelW).
Render(label + "\n" + inputLine)
hint := s.Faint.Render("[enter] confirm [esc] cancel")
escKey := keys.Keys.Global.Escape.Help().Key
hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel")
var sb strings.Builder
sb.WriteString(center(iw, panel))
+10 -3
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
@@ -27,8 +28,14 @@ func parseRawRequest(content string, req *intercept.PendingRequest) {
if parsed.Proto != "" {
r.Proto = parsed.Proto
}
if parsed.Host != "" {
r.URL.Host = parsed.Host
}
r.Header = make(http.Header)
for _, h := range parsed.Headers {
if strings.EqualFold(h.Key, "host") {
continue
}
r.Header.Set(h.Key, h.Value)
}
if parsed.Body != "" {
@@ -157,20 +164,20 @@ func (m *Model) listHalfWidths() (leftW, rightW int) {
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
bodyInner := m.width - 2
if bodyInner < 0 {
bodyInner = 0
}
bodyVH := style.PanelContentH(bodyH)
bodyVH := ilovetui.ContentHeight(bodyH)
m.textarea.SetWidth(bodyInner)
m.textarea.SetHeight(bodyVH)
m.bodyViewport.SetWidth(bodyInner)
m.bodyViewport.SetHeight(bodyVH)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
+3 -14
View File
@@ -1,29 +1,18 @@
package intercept
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func newHelp() help.Model { return style.NewHelp() }
type interceptKeyMap struct{ width int }
func iconBinding(b key.Binding, icon string) key.Binding {
h := b.Help()
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
}
func (interceptKeyMap) ShortHelp() []key.Binding {
ic := keys.Keys.Intercept
i := icons.I
return []key.Binding{
iconBinding(ic.Forward, i.Forward),
iconBinding(ic.Drop, i.Drop),
iconBinding(ic.Edit, i.Edit),
ic.Forward,
ic.Drop,
ic.Edit,
keys.Keys.Global.Help,
}
}
+8 -8
View File
@@ -6,9 +6,9 @@ import (
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
)
type panel int
@@ -48,14 +48,14 @@ type Model struct {
func New(broker *intercept.Broker) Model {
cfg := config.Global
ta := style.NewTextarea(false)
ta := ilovetui.NewTextarea(false)
ta.Blur()
lv := style.NewViewport()
rv := style.NewViewport()
bv := style.NewViewport()
p := style.NewPaginator()
rp := style.NewPaginator()
lv := ilovetui.NewViewport()
rv := ilovetui.NewViewport()
bv := ilovetui.NewViewport()
p := ilovetui.NewPaginator()
rp := ilovetui.NewPaginator()
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
@@ -69,7 +69,7 @@ func New(broker *intercept.Broker) Model {
textarea: ta,
pager: p,
responsePager: rp,
help: newHelp(),
help: ilovetui.NewHelp(),
pendingEdits: make(map[*intercept.PendingRequest]string),
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
}
-15
View File
@@ -287,8 +287,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
}
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit()
@@ -296,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
m.textarea.Blur()
m.refreshBodyViewport()
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
}
}
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
+29 -36
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
var listRow string
if m.captureResponse {
@@ -38,17 +39,15 @@ func (m Model) View() tea.View {
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
border := s.Panel
border := ilovetui.S.Panel
if focused {
border = s.PanelFocused
border = ilovetui.S.PanelFocused
}
var dots string
if len(m.queue) > 0 {
dots = s.Faint.Render(m.pager.View())
dots = ilovetui.S.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
@@ -57,46 +56,42 @@ func (m *Model) renderListPanel(w, h int) string {
title := icons.I.Request + "Requests"
if !m.interceptEnabled {
title += " " + lipgloss.NewStyle().Foreground(style.S.Error).Render("[intercept off]")
title += " " + lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render("[intercept off]")
}
return style.RenderWithTitle(border, title, inner, w, h)
return ilovetui.RenderWithTitle(border, title, inner, w, h)
}
func (m *Model) renderResponseListPanel(w, h int) string {
s := style.S
focused := !m.editing && m.focusedPanel == panelResponses
border := s.Panel
border := ilovetui.S.Panel
if focused {
border = s.PanelFocused
border = ilovetui.S.PanelFocused
}
dots := s.Faint.Render(m.responsePager.View())
dots := ilovetui.S.Faint.Render(m.responsePager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.responseViewport.View(),
lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h)
return ilovetui.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h)
}
func (m *Model) renderBodyPanel(h int) string {
s := style.S
var body string
if m.editing {
body = m.textarea.View()
} else {
body = style.ViewportView(&m.bodyViewport)
body = ilovetui.ViewportView(&m.bodyViewport)
}
border := s.Panel
border := ilovetui.S.Panel
if m.editing {
border = s.PanelFocused
border = ilovetui.S.PanelFocused
}
title := icons.I.Detail + "Details"
return style.RenderWithTitle(border, title, body, m.width, h)
return ilovetui.RenderWithTitle(border, title, body, m.width, h)
}
func (m *Model) renderStatusBar() string {
@@ -105,10 +100,9 @@ func (m *Model) renderStatusBar() string {
func (m *Model) renderList() string {
if len(m.queue) == 0 {
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "waiting for a request")))
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(。◕‿‿◕。)", "waiting for a request")))
}
s := style.S
start, end := util.PageBounds(m.pager, len(m.queue))
var sb strings.Builder
@@ -121,7 +115,7 @@ func (m *Model) renderList() string {
}
selected := globalIdx == m.cursor
selBg := s.Selection
selBg := ilovetui.S.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 2
@@ -134,18 +128,18 @@ func (m *Model) renderList() string {
if selected {
bg := lipgloss.NewStyle().Background(selBg)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
s.Method(r.Method).Background(selBg).Render(r.Method),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
style.S.Method(r.Method).Background(selBg).Render(r.Method),
bg.Width(2).Render(""),
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(r.Method).Render(r.Method),
s.Faint.Render(" "),
s.Bold.Render(r.URL.Host),
s.Faint.Render(path),
style.S.Method(r.Method).Render(r.Method),
ilovetui.S.Faint.Render(" "),
ilovetui.S.Bold.Render(r.URL.Host),
ilovetui.S.Faint.Render(path),
)
}
sb.WriteString(line + "\n")
@@ -155,10 +149,9 @@ func (m *Model) renderList() string {
func (m *Model) renderResponseList() string {
if len(m.responseQueue) == 0 {
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(҂◡_◡)", "no response yet")))
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(҂◡_◡)", "no response yet")))
}
s := style.S
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
var sb strings.Builder
@@ -177,7 +170,7 @@ func (m *Model) renderResponseList() string {
statusStr := fmt.Sprintf("%d", code)
selected := globalIdx == m.responseCursor
selBg := s.Selection
selBg := ilovetui.S.Selection
statusSt := style.StatusStyle(code, 7)
@@ -192,7 +185,7 @@ func (m *Model) renderResponseList() string {
if selected {
bg := lipgloss.NewStyle().Background(selBg)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
statusSt.Background(selBg).Render(statusStr),
bg.Width(2).Render(""),
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
@@ -201,9 +194,9 @@ func (m *Model) renderResponseList() string {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
statusSt.Render(statusStr),
s.Faint.Render(" "),
s.Bold.Render(f.Request.URL.Host),
s.Faint.Render(path),
ilovetui.S.Faint.Render(" "),
ilovetui.S.Bold.Render(f.Request.URL.Host),
ilovetui.S.Faint.Render(path),
)
}
sb.WriteString(line + "\n")
+9 -9
View File
@@ -10,9 +10,9 @@ import (
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
@@ -36,7 +36,7 @@ type Model struct {
}
func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(true)
ta := ilovetui.NewTextarea(true)
ta.Placeholder = "plugin configuration..."
ta.Blur()
@@ -45,12 +45,12 @@ func New(mgr *plugins.Manager) Model {
return Model{
manager: mgr,
listViewport: style.NewViewport(),
detailViewport: style.NewViewport(),
listViewport: ilovetui.NewViewport(),
detailViewport: ilovetui.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(),
}
}
@@ -70,14 +70,14 @@ func (m *Model) recalcSizes() {
}
m.help.SetWidth(m.width - 2)
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
inner := m.width - 2
if inner < 0 {
inner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
@@ -90,7 +90,7 @@ func (m *Model) recalcSizes() {
m.filterInput.SetWidth(inner - 2)
detailContentH := style.PanelContentH(detailH)
detailContentH := ilovetui.ContentHeight(detailH)
const headerH = 2
const configFixedH = 2 // blank line + label line
textareaH := max(3, detailContentH/3)
+31 -36
View File
@@ -7,19 +7,18 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil {
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded"))))
return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.width, "(._.)~*.'", "no plugins loaded"))))
}
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
@@ -30,36 +29,34 @@ func (m Model) View() tea.View {
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.PanelFocused
panelStyle := ilovetui.S.PanelFocused
if m.editing {
panelStyle = s.Panel
panelStyle = ilovetui.S.Panel
}
var dots string
if len(m.filtered) > 0 {
dots = s.Faint.Render(m.pager.View())
dots = ilovetui.S.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
return ilovetui.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
}
func (m *Model) renderDetailPanel(h int) string {
s := style.S
panelStyle := s.Panel
panelStyle := ilovetui.S.Panel
if m.editing {
panelStyle = s.PanelFocused
panelStyle = ilovetui.S.PanelFocused
}
info, ok := m.selected()
if !ok {
return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
return ilovetui.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
}
statusSt := lipgloss.NewStyle().Foreground(s.Error)
statusSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success)
statusSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
}
status := "disabled"
if info.Enabled {
@@ -69,26 +66,26 @@ func (m *Model) renderDetailPanel(h int) string {
pad := lipgloss.NewStyle().Padding(0, 1)
header := pad.Render(
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
s.Faint.Render(filepath.Base(info.FilePath)),
ilovetui.S.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
ilovetui.S.Faint.Render(filepath.Base(info.FilePath)),
)
parts := []string{header, style.ViewportView(&m.detailViewport)}
parts := []string{header, ilovetui.ViewportView(&m.detailViewport)}
if m.hasConfig() {
var configLabel string
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
configLabel = pad.Render(ilovetui.S.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
configLabel = pad.Render(ilovetui.S.Faint.Render("config (" + editKey + " to edit):"))
}
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
}
inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
return ilovetui.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
}
func renderPluginDescription(desc string, width int) string {
@@ -100,7 +97,7 @@ func renderPluginDescription(desc string, width int) string {
desc = strings.Join(lines, "\n")
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
glamour.WithWordWrap(width),
)
if err != nil {
@@ -114,32 +111,30 @@ func renderPluginDescription(desc string, width int) string {
}
func (m *Model) renderStatusBar() string {
s := style.S
pad := lipgloss.NewStyle().Padding(0, 1)
filterKey := keys.Keys.Plugins.Filter.Help().Key
if m.filtering {
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
}
if m.filter != "" {
escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filter) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width})))
}
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width}))
}
func (m *Model) renderList() string {
s := style.S
if len(m.filtered) == 0 {
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
msg := util.EmptyState(m.listViewport.Width(), "(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
if m.filter != "" {
msg = util.CenterLines("= _ =", "no results")
msg = util.EmptyState(m.listViewport.Width(), "= _ =", "no results")
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
ilovetui.S.Faint.Render(msg),
)
}
@@ -150,10 +145,10 @@ func (m *Model) renderList() string {
globalIdx := start + i
selected := globalIdx == m.cursor
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
enabledSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
enabledStr := "off"
if p.Enabled {
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
enabledSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
enabledStr = "on "
}
@@ -166,10 +161,10 @@ func (m *Model) renderList() string {
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
enabledSt.Background(ilovetui.S.Selection).Width(3).Render(enabledStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(nameW).Render(p.Name),
)
@@ -178,7 +173,7 @@ func (m *Model) renderList() string {
" ",
enabledSt.Width(3).Render(enabledStr),
" ",
s.Bold.Render(p.Name),
ilovetui.S.Bold.Render(p.Name),
)
}
sb.WriteString(line + "\n")
+94 -14
View File
@@ -2,16 +2,18 @@ package replay
import (
"fmt"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type SendToReplayMsg struct {
@@ -31,6 +33,7 @@ type Entry struct {
ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet
Sending bool
Flagged bool
Err error
}
@@ -43,12 +46,18 @@ const (
)
type Model struct {
entries []Entry
allEntries []Entry
entries []Entry // filtered view; equals allEntries when no filter
entryIndices []int // maps entries[i] -> allEntries[i]; nil when no filter
cursor int
editing bool
focusedPanel panel
database *db.DB
filterInput textinput.Model
filterActive bool
filterAccepted bool
listViewport viewport.Model
requestViewport viewport.Model
responseViewport viewport.Model
@@ -61,21 +70,24 @@ type Model struct {
}
func New() Model {
ta := style.NewTextarea(false)
ta := ilovetui.NewTextarea(false)
ta.Blur()
ti := textinput.New()
ti.Prompt = ""
return Model{
listViewport: style.NewViewport(),
requestViewport: style.NewViewport(),
responseViewport: style.NewViewport(),
listViewport: ilovetui.NewViewport(),
requestViewport: ilovetui.NewViewport(),
responseViewport: ilovetui.NewViewport(),
textarea: ta,
pager: style.NewPaginator(),
help: style.NewHelp(),
filterInput: ti,
pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsEditing() bool { return m.editing || (m.filterActive && !m.filterAccepted) }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
@@ -111,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
return
}
for _, dbe := range entries {
m.entries = append(m.entries, entryFromDB(dbe))
m.allEntries = append(m.allEntries, entryFromDB(dbe))
}
m.entries = m.allEntries
m.pager.SetTotalPages(len(m.entries))
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
@@ -136,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode,
Flagged: dbe.Flagged,
Err: err,
}
}
@@ -148,14 +162,15 @@ func (m *Model) SetSize(w, h int) {
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
m.filterInput.SetWidth(m.width - 4)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
listInner := m.width - 2
if listInner < 0 {
listInner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
@@ -175,7 +190,7 @@ func (m *Model) recalcSizes() {
if rightInner < 0 {
rightInner = 0
}
bodyVH := style.PanelContentH(bodyH)
bodyVH := ilovetui.ContentHeight(bodyH)
m.requestViewport.SetWidth(leftInner)
m.requestViewport.SetHeight(bodyVH)
@@ -194,12 +209,77 @@ func (m *Model) bodyHalfWidths() (left, right int) {
return
}
func (m *Model) currentAllIdx() int {
if m.entryIndices != nil && m.cursor < len(m.entryIndices) {
return m.entryIndices[m.cursor]
}
if m.cursor < len(m.allEntries) {
return m.cursor
}
return -1
}
func matchesFilter(e Entry, term string) bool {
return strings.Contains(strings.ToLower(e.Host), term) ||
strings.Contains(strings.ToLower(e.Path), term) ||
strings.Contains(strings.ToLower(strings.TrimSpace(e.Method)), term)
}
func (m *Model) applyFilter(preferAllIdx int) {
term := strings.ToLower(m.filterInput.Value())
if !m.filterActive || term == "" {
m.entries = m.allEntries
m.entryIndices = nil
if preferAllIdx >= 0 && preferAllIdx < len(m.allEntries) {
m.cursor = preferAllIdx
}
} else {
m.entries = nil
m.entryIndices = nil
newCursor := 0
found := false
for i, e := range m.allEntries {
if matchesFilter(e, term) {
if i == preferAllIdx && !found {
newCursor = len(m.entries)
found = true
}
m.entries = append(m.entries, e)
m.entryIndices = append(m.entryIndices, i)
}
}
if found {
m.cursor = newCursor
} else {
m.cursor = 0
}
}
if len(m.entries) == 0 {
m.cursor = 0
} else if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) clearFilter() {
prevAllIdx := m.currentAllIdx()
m.filterActive = false
m.filterAccepted = false
m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(prevAllIdx)
}
type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help}
}
func (m replayKeyMap) FullHelp() [][]key.Binding {
+146 -43
View File
@@ -5,9 +5,14 @@ import (
"compress/gzip"
"compress/zlib"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
@@ -16,6 +21,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/andybalholm/brotli"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
@@ -61,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
entry.DBID = id
}
}
m.entries = append(m.entries, entry)
m.cursor = len(m.entries) - 1
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.allEntries = append(m.allEntries, entry)
m.applyFilter(len(m.allEntries) - 1)
case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) {
e := &m.entries[msg.index]
allIdx := msg.index
if allIdx >= 0 && allIdx < len(m.allEntries) {
e := m.allEntries[allIdx]
e.Sending = false
e.StatusCode = msg.statusCode
e.ResponseRaw = msg.responseRaw
@@ -78,11 +82,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
e.ResponseRaw = "Error: " + msg.err.Error()
}
if m.database != nil && e.DBID != 0 {
m.database.UpdateReplayEntry(entryToDB(*e))
if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil {
log.Printf("replay: update entry: %v", err)
}
}
m.allEntries[allIdx] = e
}
m.refreshListViewport()
m.refreshBody()
prevAllIdx := m.currentAllIdx()
m.applyFilter(prevAllIdx)
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
@@ -95,24 +102,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
m.scrollBothViewports(true)
} else {
m.scrollFocusedViewportVertical(-1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
m.scrollBothViewports(false)
} else {
m.scrollFocusedViewportVertical(1)
}
case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
m.scrollBothViewports(true)
case tea.MouseWheelRight:
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
m.scrollBothViewports(false)
}
}
@@ -129,6 +132,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
r := keys.Keys.Replay
if m.filterActive && !m.filterAccepted {
switch {
case key.Matches(msg, g.Escape):
m.clearFilter()
case msg.String() == "enter":
m.filterAccepted = true
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
prevAllIdx := m.currentAllIdx()
m.applyFilter(prevAllIdx)
return m, cmd
}
return m, nil
}
if m.filterActive && m.filterAccepted {
if key.Matches(msg, g.Escape) {
m.clearFilter()
return m, nil
}
}
switch {
case key.Matches(msg, g.Up):
if m.focusedPanel == panelList {
@@ -164,12 +193,13 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
m.entries[m.cursor].Sending = true
m.entries[m.cursor].ResponseRaw = ""
m.entries[m.cursor].Err = nil
m.refreshListViewport()
m.refreshBody()
return m, sendCmd(m.entries[m.cursor], m.cursor)
allIdx := m.currentAllIdx()
m.allEntries[allIdx].Sending = true
m.allEntries[allIdx].ResponseRaw = ""
m.allEntries[allIdx].Err = nil
entry := m.allEntries[allIdx]
m.applyFilter(allIdx)
return m, sendCmd(entry, allIdx)
}
case key.Matches(msg, r.Edit):
@@ -211,30 +241,67 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
case key.Matches(msg, r.Flag):
if len(m.entries) > 0 {
allIdx := m.currentAllIdx()
if m.database != nil && m.allEntries[allIdx].DBID != 0 {
if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil {
log.Printf("replay: toggle flag: %v", err)
}
}
m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged
m.applyFilter(allIdx)
}
case key.Matches(msg, r.Delete):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
allIdx := m.currentAllIdx()
e := m.allEntries[allIdx]
if m.database != nil && e.DBID != 0 {
m.database.DeleteReplayEntry(e.DBID)
if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
log.Printf("replay: delete entry: %v", err)
}
}
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 {
m.cursor--
m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
preferAllIdx := allIdx
if preferAllIdx >= len(m.allEntries) {
preferAllIdx = len(m.allEntries) - 1
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.applyFilter(preferAllIdx)
}
case key.Matches(msg, r.DeleteAll):
if m.database != nil {
m.database.DeleteAllReplayEntries()
if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil {
log.Printf("replay: delete all entries: %v", err)
}
}
// Mirror the DB logic in memory: delete unflagged first; if none, delete all.
hasUnflagged := false
for _, e := range m.allEntries {
if !e.Flagged {
hasUnflagged = true
break
}
}
if hasUnflagged {
filtered := m.allEntries[:0]
for _, e := range m.allEntries {
if e.Flagged {
filtered = append(filtered, e)
}
}
m.allEntries = filtered
} else {
m.allEntries = nil
}
m.entries = nil
m.cursor = 0
m.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
m.filterActive = false
m.filterAccepted = false
m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(-1)
case key.Matches(msg, keys.Keys.Global.GotoTop):
m.cursor = 0
@@ -275,6 +342,15 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
case key.Matches(msg, r.Filter):
if m.focusedPanel == panelList {
m.filterActive = true
m.filterAccepted = false
m.filterInput.Placeholder = "filter requests..."
m.filterInput.Focus()
m.recalcSizes()
}
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
@@ -319,6 +395,16 @@ func (m *Model) setFocusedViewport(vp viewport.Model) {
}
}
func (m *Model) scrollBothViewports(left bool) {
if left {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
}
}
func (m *Model) scrollFocusedViewportVertical(delta int) {
vp := m.focusedViewport()
vp.SetYOffset(vp.YOffset() + delta)
@@ -350,11 +436,11 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0)
if e.Sending {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(ノ◕ヮ◕)ノ*:・゚", "sending..."))))
} else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "( •_•)>⌐■", "press send to fire"))))
}
m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0)
@@ -395,11 +481,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
}
req.Header = headers
tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config
if !config.Global.App.SslInsecure {
if certPool, err := x509.SystemCertPool(); err == nil {
caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem")
if pem, err := os.ReadFile(caPath); err == nil {
certPool.AppendCertsFromPEM(pem)
}
tlsCfg.RootCAs = certPool
}
}
transport := &http.Transport{
TLSClientConfig: tlsCfg,
}
if up := config.Global.App.UpstreamProxy; up != "" {
if proxyURL, err := url.Parse(up); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
}
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
Timeout: 30 * time.Second,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -479,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry {
ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode,
ErrorMsg: errMsg,
Flagged: e.Flagged,
}
}
+49 -29
View File
@@ -6,7 +6,9 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
@@ -16,7 +18,7 @@ func (m Model) View() tea.View {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
leftW, rightW := m.bodyHalfWidths()
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
@@ -33,49 +35,57 @@ func (m Model) View() tea.View {
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.Panel
panelStyle := ilovetui.S.Panel
if !m.editing && m.focusedPanel == panelList {
panelStyle = s.PanelFocused
panelStyle = ilovetui.S.PanelFocused
}
var dots string
if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View())
dots = ilovetui.S.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
return ilovetui.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
}
func (m *Model) renderRequestPanel(w, h int) string {
s := style.S
var body string
border := s.Panel
border := ilovetui.S.Panel
if m.editing {
body = m.textarea.View()
border = s.PanelFocused
border = ilovetui.S.PanelFocused
} else {
body = style.ViewportView(&m.requestViewport)
body = ilovetui.ViewportView(&m.requestViewport)
if m.focusedPanel == panelRequest {
border = s.PanelFocused
border = ilovetui.S.PanelFocused
}
}
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
return ilovetui.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
}
func (m *Model) renderResponsePanel(w, h int) string {
s := style.S
border := s.Panel
border := ilovetui.S.Panel
if !m.editing && m.focusedPanel == panelResponse {
border = s.PanelFocused
border = ilovetui.S.PanelFocused
}
return style.RenderWithTitle(border, icons.I.Response+"Response", style.ViewportView(&m.responseViewport), w, h)
return ilovetui.RenderWithTitle(border, icons.I.Response+"Response", ilovetui.ViewportView(&m.responseViewport), w, h)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width}))
pad := lipgloss.NewStyle().Padding(0, 1)
if m.filterActive {
filterKey := keys.Keys.Replay.Filter.Help().Key
escKey := keys.Keys.Global.Escape.Help().Key
if m.filterAccepted {
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filterInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(replayKeyMap{width: m.width})))
}
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
}
return pad.Render(m.help.View(replayKeyMap{width: m.width}))
}
func (m *Model) renderList() string {
@@ -83,48 +93,58 @@ func (m *Model) renderList() string {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
style.S.Faint.Render(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")),
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(╥﹏╥)", "send a request from History or Intercept")),
)
}
s := style.S
start, end := util.PageBounds(m.pager, len(m.entries))
var sb strings.Builder
for i, e := range m.entries[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
selBg := ilovetui.S.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1
const fixedW = 2 + 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
statusStr, statusSt := entryStatus(e)
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string
if selected {
bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
s.Method(e.Method).Background(selBg).Render(e.Method),
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
style.S.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(e.Method).Render(e.Method),
flagSt.Width(2).Render(flagStr),
style.S.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
ilovetui.S.Bold.Render(e.Host),
ilovetui.S.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
@@ -136,11 +156,11 @@ func entryStatus(e Entry) (string, lipgloss.Style) {
base := lipgloss.NewStyle().Bold(true).Width(3)
switch {
case e.Sending:
return "···", base.Foreground(style.S.Subtle)
return "···", base.Foreground(ilovetui.S.Subtle)
case e.Err != nil:
return "ERR", base.Foreground(style.S.Error)
return "ERR", base.Foreground(ilovetui.S.Error)
case e.StatusCode == 0:
return "---", base.Foreground(style.S.Subtle)
return "---", base.Foreground(ilovetui.S.Subtle)
}
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
}
+7 -4
View File
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
Err error
}
func resolveEditor() string {
func ResolveEditor() string {
editor := config.Global.App.ExternalEditor
if editor == "" {
editor = os.Getenv("EDITOR")
@@ -29,14 +29,17 @@ func resolveEditor() string {
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
f, err := os.CreateTemp("", "spilltea-*.http")
if err != nil {
log.Printf("editor: create temp file: %v", err)
return nil
}
tmpPath := f.Name()
if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
f.Close()
return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg {
if err := f.Close(); err != nil {
log.Printf("editor: closing temp file: %v", err)
}
return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
defer os.Remove(tmpPath)
return callback(tmpPath, err)
})
@@ -47,7 +50,7 @@ func OpenExternalEditor(content string) tea.Cmd {
if err != nil {
return EditorFinishedMsg{Err: err}
}
data, readErr := os.ReadFile(tmpPath)
data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process
if readErr != nil {
return EditorFinishedMsg{Err: readErr}
}
+8
View File
@@ -14,6 +14,14 @@ func Truncate(s string, max int) string {
return s[:max-1] + "…"
}
// EmptyState renders centered placeholder lines, word-wrapping to fit maxW.
func EmptyState(maxW int, lines ...string) string {
return lipgloss.NewStyle().
Width(maxW).
AlignHorizontal(lipgloss.Center).
Render(strings.Join(lines, "\n"))
}
// CenterLines centers each line horizontally relative to the longest one.
func CenterLines(lines ...string) string {
maxWidth := 0
+61
View File
@@ -0,0 +1,61 @@
{pkgs}: let
defaultCertDir = "$HOME/.local/share/spilltea";
defaultHost = "127.0.0.1";
defaultPort = "8080";
in
pkgs.writeShellApplication {
name = "spilltea-browser";
runtimeInputs = with pkgs; [nss.tools librewolf netcat-gnu procps];
text = ''
CERT_DIR="${defaultCertDir}"
HOST="${defaultHost}"
PORT="${defaultPort}"
while [[ $# -gt 0 ]]; do
case "$1" in
--cert-dir) CERT_DIR="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
CERT_FILE="$CERT_DIR/mitmproxy-ca-cert.pem"
if [[ ! -f "$CERT_FILE" ]]; then
echo "Certificate not found at $CERT_FILE"
echo "Launch spilltea first, or use --cert-dir if you changed cert_dir in your config."
exit 1
fi
if ! pgrep -x spilltea > /dev/null 2>&1; then
echo "spilltea is not running. Launch spilltea first."
exit 1
fi
if ! nc -z "$HOST" "$PORT" > /dev/null 2>&1; then
echo "Nothing is listening on $HOST:$PORT."
echo "Is spilltea configured on that port? Use --host and --port if you changed them in your config."
exit 1
fi
PROFILE=$(mktemp -d)
trap 'rm -rf "$PROFILE"' EXIT
certutil -N -d sql:"$PROFILE" --empty-password
certutil -d sql:"$PROFILE" -A -n "spilltea" -t "CT,," -i "$CERT_FILE"
cat > "$PROFILE/user.js" <<EOF
user_pref("network.proxy.type", 1);
user_pref("network.proxy.http", "$HOST");
user_pref("network.proxy.http_port", $PORT);
user_pref("network.proxy.ssl", "$HOST");
user_pref("network.proxy.ssl_port", $PORT);
user_pref("network.proxy.no_proxies_on", "");
user_pref("network.stricttransportsecurity.preloadlist", false);
user_pref("dom.security.https_only_mode", false);
EOF
librewolf --profile "$PROFILE" --no-remote
'';
}
+16 -4
View File
@@ -25,6 +25,10 @@ schema = 3
version = 'v1.0.4'
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
[mod.'github.com/anotherhadi/ilovetui']
version = 'v0.1.6'
hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4='
[mod.'github.com/atotto/clipboard']
version = 'v0.1.4'
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
@@ -101,6 +105,10 @@ schema = 3
version = 'v1.5.0'
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
[mod.'github.com/inconshreveable/mousetrap']
version = 'v1.1.0'
hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE='
[mod.'github.com/klauspost/compress']
version = 'v1.17.8'
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
@@ -169,6 +177,10 @@ schema = 3
version = 'v1.10.0'
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
[mod.'github.com/spf13/cobra']
version = 'v1.10.2'
hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k='
[mod.'github.com/spf13/pflag']
version = 'v1.0.10'
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
@@ -206,16 +218,16 @@ schema = 3
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
[mod.'golang.org/x/net']
version = 'v0.54.0'
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
version = 'v0.55.0'
hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s='
[mod.'golang.org/x/sync']
version = 'v0.20.0'
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
[mod.'golang.org/x/sys']
version = 'v0.44.0'
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
version = 'v0.45.0'
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
[mod.'golang.org/x/text']
version = 'v0.37.0'
+11 -1
View File
@@ -2,13 +2,22 @@
pkgs,
buildGoApplication,
}: let
browser = import ./browser.nix {inherit pkgs;};
pname = "spilltea";
version = "0.0.6";
version = "0.0.8";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;
src = ../.;
modules = ./gomod2nix.toml;
nativeBuildInputs = [pkgs.installShellFiles];
env.GOTOOLCHAIN = "local";
postInstall = ''
installShellCompletion --cmd spilltea \
--bash <($out/bin/spilltea completion bash) \
--zsh <($out/bin/spilltea completion zsh) \
--fish <($out/bin/spilltea completion fish)
'';
meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
homepage = "https://github.com/anotherhadi/spilltea";
@@ -18,4 +27,5 @@
in {
"${pname}" = pkg;
default = pkg;
browser = browser;
}
+2
View File
@@ -52,6 +52,8 @@ in
packages = with pkgs;
[
go
gosec
govulncheck
python3
doctoc
stylua
+2 -2
View File
@@ -17,7 +17,7 @@ var PluginsFS embed.FS
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
// files that already exist. Returns the number of files written.
func InstallDefaultPlugins(dir string) (int, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o750); err != nil {
return 0, fmt.Errorf("create plugins dir: %w", err)
}
@@ -39,7 +39,7 @@ func InstallDefaultPlugins(dir string) (int, error) {
if err != nil {
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
if err := os.WriteFile(dst, data, 0o600); err != nil {
return written, fmt.Errorf("write %s: %w", dst, err)
}
written++