mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
48 Commits
v0.0.5
..
31bb2aa439
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bb2aa439 | |||
| b3caa9852c | |||
| 39248e2be6 | |||
| 9d0ef598a7 | |||
| 77b102791f | |||
| 2e0ad98a4a | |||
| b0afb7cb56 | |||
| acee3df1bc | |||
| 61a112a302 | |||
| 677223c9b0 | |||
| 098400ba77 | |||
| 4922f3704d | |||
| 1aa4b92bb3 | |||
| 437e8f883d | |||
| 5c74eda48b | |||
| 1190276bab | |||
| 6128dcc15e | |||
| 41d5366952 | |||
| 8683d17242 | |||
| 2e61ffa125 | |||
| 9ac6040629 | |||
| c7523e69e7 | |||
| aa458c142f | |||
| 0b395e018a | |||
| 5841ed0a95 | |||
| 9cabe81771 | |||
| 021090f52c | |||
| 0b9e1a1cf0 | |||
| f60cdf2033 | |||
| 6af1388652 | |||
| a708830309 | |||
| 44b3c67a37 | |||
| 6e673f5c11 | |||
| 2c63cdbeff | |||
| 0e982c6ade | |||
| 04ba32cbd5 | |||
| f7e9da94cc | |||
| 9253d85c81 | |||
| 1bb547870e | |||
| 4251e4fb2a | |||
| b547a79d6e | |||
| fe58468abf | |||
| 3542098905 | |||
| f78b3f7174 | |||
| 722021ba02 | |||
| e18f660e83 | |||
| 67fe8eb911 | |||
| af872afbe8 |
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
|
|
||||||
|
|
||||||
go mod vendor
|
|
||||||
|
|
||||||
COMPUTED_HASH=$(nix hash path vendor/)
|
|
||||||
|
|
||||||
rm -rf vendor/
|
|
||||||
|
|
||||||
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
|
|
||||||
echo "vendorHash is up to date"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Updating vendorHash in flake.nix..."
|
|
||||||
python3 -c "
|
|
||||||
import sys
|
|
||||||
with open('flake.nix', 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
|
|
||||||
with open('flake.nix', 'w') as f:
|
|
||||||
f.write(content)
|
|
||||||
"
|
|
||||||
echo " Old: $CURRENT_HASH"
|
|
||||||
echo " New: $COMPUTED_HASH"
|
|
||||||
@@ -23,6 +23,6 @@ jobs:
|
|||||||
- uses: goreleaser/goreleaser-action@v6
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean
|
args: release --clean --config .github/.goreleaser.yaml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
result/
|
result/
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
> Think Burp Suite or Caido, but entirely in your terminal.
|
> Think Burp Suite or Caido, but entirely in your terminal.
|
||||||
|
|
||||||
[](go.mod)
|

|
||||||
[](https://github.com/anotherhadi/spilltea/releases)
|

|
||||||
[](LICENSE)
|

|
||||||
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
[](https://ko-fi.com/anotherhadi)
|
||||||
|
|
||||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||||
@@ -23,8 +23,10 @@
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Project Management](#project-management)
|
- [Project Management](#project-management)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
|
- [Per-project configuration](#per-project-configuration)
|
||||||
- [CLI Flags](#cli-flags)
|
- [CLI Flags](#cli-flags)
|
||||||
- [Plugin System](#plugin-system)
|
- [Plugin System](#plugin-system)
|
||||||
|
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||||
- [Deployment](#deployment)
|
- [Deployment](#deployment)
|
||||||
- [Tech Stack](#tech-stack)
|
- [Tech Stack](#tech-stack)
|
||||||
|
|
||||||
@@ -106,29 +108,49 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
|
|||||||
|
|
||||||
On startup, you choose:
|
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
|
- **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
|
## Configuration
|
||||||
|
|
||||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
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)
|
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
|
## 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-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
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-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)
|
--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 -->
|
<!-- 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.
|
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/).
|
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
|
## Deployment
|
||||||
|
|
||||||
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
|
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
|
||||||
|
|||||||
+140
-111
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
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.
|
// 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() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
flagConfig string
|
||||||
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
|
flagPluginsDir string
|
||||||
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
flagHost string
|
||||||
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
flagPort int
|
||||||
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
flagUpstreamProxy string
|
||||||
flagVersion = flag.BoolP("version", "v", false, "print version")
|
flagProject string
|
||||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
flagSslInsecure bool
|
||||||
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
flagAddDefaultPlugins bool
|
||||||
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
|
flagAddDefaultConfig bool
|
||||||
)
|
)
|
||||||
flag.CommandLine.SetOutput(os.Stdout)
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Println("Usage: spilltea [flags]\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
}
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *flagVersion {
|
rootCmd := &cobra.Command{
|
||||||
fmt.Println(version)
|
Use: "spilltea",
|
||||||
os.Exit(0)
|
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 {
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
home, _ := os.UserHomeDir()
|
rootCmd.Flags().StringVar(&flagPluginsDir, "plugins-dir", "", "path to plugins dir (overrides config)")
|
||||||
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
|
||||||
if *flagConfig != "" {
|
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
|
||||||
cfgPath = *flagConfig
|
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)")
|
||||||
if err := config.Load(cfgPath); err != nil {
|
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
||||||
os.Exit(1)
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if *flagAddDefaultConfig {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
home, _ := os.UserHomeDir()
|
fmt.Fprintln(os.Stderr, err)
|
||||||
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)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
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/intercept"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
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 m.state == rootStateHome {
|
||||||
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
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()
|
broker := intercept.NewBroker()
|
||||||
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
||||||
m.app = app
|
m.app = app
|
||||||
@@ -58,3 +69,12 @@ func (m rootModel) View() tea.View {
|
|||||||
}
|
}
|
||||||
return m.home.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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package spilltea
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed docs
|
|
||||||
var DocsFS embed.FS
|
|
||||||
+24
-4
@@ -4,29 +4,49 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
|
|||||||
|
|
||||||
On startup, you choose:
|
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
|
- **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
|
## Configuration
|
||||||
|
|
||||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
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)
|
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
|
## CLI Flags
|
||||||
|
|
||||||
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
<!-- 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-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
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
-c, --config string path to config file
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
--host string proxy host (overrides config)
|
--host string proxy host (overrides config)
|
||||||
--plugins-dir string path to plugins dir (overrides config)
|
--plugins-dir string path to plugins dir (overrides config)
|
||||||
-p, --port int proxy port (overrides config)
|
-p, --port int proxy port (overrides config)
|
||||||
-P, --project string project name to open directly, or "tmp" for a temporary session
|
-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)
|
--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 -->
|
<!-- endexec -->
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## How do I convert a temporary session into a regular project?
|
||||||
|
|
||||||
|
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
|
||||||
|
|
||||||
|
**1. Find the temporary session directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /tmp/spilltea/
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
|
||||||
|
|
||||||
|
**2. Move it to the projects folder**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The project name must only contain lowercase letters, digits, `-`, and `_`.
|
||||||
|
|
||||||
|
**3. Open the project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spilltea -P my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply launch Spilltea and pick `my-project` from the project list.
|
||||||
@@ -11,5 +11,7 @@
|
|||||||
# Spilltea Documentation
|
# Spilltea Documentation
|
||||||
|
|
||||||
- **Version**: `{{.Cfg.Version}}`
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
|
||||||
|
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
|
||||||
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
||||||
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
|
|||||||
+77
-51
@@ -1,5 +1,7 @@
|
|||||||
# Plugins
|
# Plugins
|
||||||
|
|
||||||
|
> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run.
|
||||||
|
|
||||||
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
||||||
You can found some pre-built plugins [here](../../plugins/).
|
You can found some pre-built plugins [here](../../plugins/).
|
||||||
|
|
||||||
@@ -31,14 +33,14 @@ Plugin = {
|
|||||||
|
|
||||||
### Hook reference
|
### Hook reference
|
||||||
|
|
||||||
| Hook | When called | Sync/async | Return value |
|
| Hook | When called | Sync/async | Return value |
|
||||||
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
|
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
| `on_config()` | At startup and on config save | always sync | ignored |
|
||||||
| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
|
| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
|
||||||
| `on_quit()` | When the app exits | always sync | ignored |
|
| `on_quit()` | When the app exits | always sync | ignored |
|
||||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
|
||||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
|
||||||
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) -- sync only |
|
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) (sync only) |
|
||||||
|
|
||||||
## Request and response objects
|
## Request and response objects
|
||||||
|
|
||||||
@@ -120,17 +122,77 @@ if err then
|
|||||||
else
|
else
|
||||||
log("output: " .. out)
|
log("output: " .. out)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Send an HTTP request directly (bypasses the proxy pipeline and other plugins).
|
||||||
|
-- method: HTTP method ("GET", "POST", etc.)
|
||||||
|
-- url: full URL to request
|
||||||
|
-- headers: table of request headers (optional, pass {} if unused)
|
||||||
|
-- body: request body string (optional, pass "" if unused)
|
||||||
|
-- options: optional table of options:
|
||||||
|
-- insecure = true skip TLS certificate verification
|
||||||
|
-- Returns: response table, error string (nil on success).
|
||||||
|
local res, err = send_request("POST", "https://example.com/api", {
|
||||||
|
["Authorization"] = "Bearer " .. token,
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
}, '{"key":"value"}', { insecure = true })
|
||||||
|
if err then
|
||||||
|
log("request failed: " .. err)
|
||||||
|
else
|
||||||
|
log(tostring(res.status_code))
|
||||||
|
log(res.body)
|
||||||
|
log(res.headers["Content-Type"] or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the plugin's config section as a Lua table (parsed from YAML).
|
||||||
|
-- Returns an empty table if no config is set.
|
||||||
|
local cfg = get_config()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Finding deduplication
|
### Finding deduplication
|
||||||
|
|
||||||
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts.
|
||||||
|
If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
|
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).
|
||||||
|
|
||||||
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
|
```yaml
|
||||||
|
plugins:
|
||||||
|
my_plugin:
|
||||||
|
enable: true
|
||||||
|
config:
|
||||||
|
some_key: some_value
|
||||||
|
list:
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
other_plugin:
|
||||||
|
enable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The config block is edited from the **Plugins** page in the TUI.
|
||||||
|
Inside a plugin, call `get_config()` to retrieve the config as a Lua table.
|
||||||
|
|
||||||
|
Global defaults for any plugin can be set in `~/.config/spilltea/config.yaml` under a `plugins` key with the same structure as `plugins.yaml`.
|
||||||
|
These defaults are applied the first time a plugin is loaded in a project; once the plugin has an entry in the project's `plugins.yaml`, the project config takes full precedence and the global defaults are ignored.
|
||||||
|
|
||||||
|
`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI.
|
||||||
|
It is the right place to read `get_config()` and populate local variables.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
function on_config()
|
||||||
|
items = {}
|
||||||
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.list then
|
||||||
|
for _, v in ipairs(cfg.list) do
|
||||||
|
table.insert(items, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
## Sync vs async
|
## Sync vs async
|
||||||
|
|
||||||
@@ -139,49 +201,13 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
|||||||
|
|
||||||
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
||||||
|
|
||||||
### Return values for sync hooks
|
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history.
|
||||||
|
Async `on_history_entry` runs **after** the insert and cannot affect it.
|
||||||
**`on_start`:**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ------------ | -------------------------------------------------------------------------------------------- |
|
|
||||||
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
|
|
||||||
| anything else | Ignored. |
|
|
||||||
|
|
||||||
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
function on_start()
|
|
||||||
local h = io.popen("command -v mytool 2>/dev/null")
|
|
||||||
local result = h and h:read("*a") or ""
|
|
||||||
if h then h:close() end
|
|
||||||
if result:match("^%s*$") then
|
|
||||||
notif("MyPlugin", "mytool not found, plugin disabled", "error")
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**`on_request` and `on_response`:**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ------------ | --------------------------------------------------------------------------------- |
|
|
||||||
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
|
|
||||||
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
|
|
||||||
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
|
|
||||||
|
|
||||||
**`on_history_entry` (sync only):**
|
|
||||||
|
|
||||||
| Return value | Effect |
|
|
||||||
| ----------------- | --------------------------------- |
|
|
||||||
| `"skip"` | The entry is not saved to the DB. |
|
|
||||||
| `"keep"` or `nil` | The entry is saved normally. |
|
|
||||||
|
|
||||||
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
|
|
||||||
|
|
||||||
## Priority
|
## Priority
|
||||||
|
|
||||||
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
|
Plugins with a higher `priority` value run before plugins with a lower value (default `0`).
|
||||||
|
This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
Plugin = {
|
Plugin = {
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,12 @@
|
|||||||
## Configuring your browser's proxy settings
|
## 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)
|
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.
|
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
||||||
|
|||||||
Generated
+115
@@ -1,5 +1,103 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778507602,
|
||||||
|
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gomod2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770585520,
|
||||||
|
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1777954456,
|
||||||
@@ -18,8 +116,25 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"gomod2nix": "gomod2nix",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -1,55 +1,41 @@
|
|||||||
{
|
{
|
||||||
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
|
||||||
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
gomod2nix = {
|
||||||
|
url = "github:nix-community/gomod2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
git-hooks = {
|
||||||
|
url = "github:cachix/git-hooks.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
gomod2nix,
|
||||||
|
git-hooks,
|
||||||
}: let
|
}: let
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
forAllSystems = f:
|
forAllSystems = f:
|
||||||
nixpkgs.lib.genAttrs supportedSystems
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "spilltea";
|
|
||||||
version = "0.0.5";
|
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
|
||||||
in {
|
in {
|
||||||
packages = forAllSystems (system: pkgs: let
|
packages = forAllSystems (system: pkgs:
|
||||||
pkg = pkgs.buildGoModule {
|
import ./nix/package.nix {
|
||||||
inherit pname version ldflags;
|
inherit pkgs;
|
||||||
|
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||||
src = ./.;
|
});
|
||||||
outputs = ["out"];
|
|
||||||
|
|
||||||
vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
|
||||||
homepage = "https://github.com/anotherhadi/spilltea";
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
"${pname}" = pkg;
|
|
||||||
default = pkg;
|
|
||||||
});
|
|
||||||
|
|
||||||
devShells = forAllSystems (system: pkgs: {
|
devShells = forAllSystems (system: pkgs: {
|
||||||
default = pkgs.mkShell {
|
default = import ./nix/shell.nix {
|
||||||
packages = with pkgs; [
|
inherit pkgs;
|
||||||
go
|
gitHooksLib = git-hooks.lib.${system};
|
||||||
python3
|
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||||
lefthook
|
|
||||||
doctoc
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
lefthook install
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ require (
|
|||||||
charm.land/bubbletea/v2 v2.0.6
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
charm.land/glamour/v2 v2.0.0
|
charm.land/glamour/v2 v2.0.0
|
||||||
charm.land/lipgloss/v2 v2.0.3
|
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/charmbracelet/x/ansi v0.11.7
|
||||||
|
github.com/klauspost/compress v1.17.8
|
||||||
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
||||||
github.com/sirupsen/logrus v1.9.4
|
github.com/sirupsen/logrus v1.9.4
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.1
|
modernc.org/sqlite v1.50.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
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/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
@@ -39,7 +41,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // 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/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.23 // 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/satori/go.uuid v1.2.0 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.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/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
@@ -61,7 +64,7 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.20.0 // 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
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
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/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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
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/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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
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/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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
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/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 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
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/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 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -28,10 +28,10 @@ type Config struct {
|
|||||||
ProxyAuth string `mapstructure:"proxy_auth"`
|
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||||
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||||
ExternalEditor string `mapstructure:"external_editor"`
|
ExternalEditor string `mapstructure:"external_editor"`
|
||||||
|
SslInsecure bool `mapstructure:"ssl_insecure"`
|
||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
Colors Colors `mapstructure:"colors"`
|
|
||||||
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
||||||
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
||||||
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
||||||
@@ -41,6 +41,7 @@ type Config struct {
|
|||||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||||
|
QueueSize int `mapstructure:"queue_size"`
|
||||||
} `mapstructure:"intercept"`
|
} `mapstructure:"intercept"`
|
||||||
|
|
||||||
Replay struct {
|
Replay struct {
|
||||||
@@ -53,10 +54,54 @@ type Config struct {
|
|||||||
} `mapstructure:"history"`
|
} `mapstructure:"history"`
|
||||||
|
|
||||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||||
|
|
||||||
|
Plugins map[string]GlobalPlugin `mapstructure:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalPlugin struct {
|
||||||
|
Enable *bool `mapstructure:"enable"`
|
||||||
|
Config interface{} `mapstructure:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Config
|
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 {
|
func Load(path string) error {
|
||||||
var defaults map[string]any
|
var defaults map[string]any
|
||||||
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||||
@@ -75,14 +120,19 @@ func Load(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Global = &Config{}
|
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 {
|
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)
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
|
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||||
return fmt.Errorf("write config: %w", err)
|
return fmt.Errorf("write config: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -99,6 +149,17 @@ func ExpandPath(p string) string {
|
|||||||
return p
|
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 {
|
func flatten(prefix string, m map[string]any) map[string]any {
|
||||||
out := make(map[string]any)
|
out := make(map[string]any)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ app:
|
|||||||
cert_dir: ~/.local/share/spilltea
|
cert_dir: ~/.local/share/spilltea
|
||||||
project_dir: ~/.local/share/spilltea
|
project_dir: ~/.local/share/spilltea
|
||||||
plugins_dir: ~/.config/spilltea/plugins
|
plugins_dir: ~/.config/spilltea/plugins
|
||||||
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
|
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
|
||||||
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled)
|
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)
|
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)
|
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:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
|
queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
@@ -26,27 +28,11 @@ tui:
|
|||||||
use_nerdfont_icons: false
|
use_nerdfont_icons: false
|
||||||
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
||||||
pretty_print_body: true # auto-indent JSON and HTML response bodies
|
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:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
quit: "q,ctrl+c"
|
quit: "q,ctrl+c"
|
||||||
|
escape: "esc,ctrl+c"
|
||||||
help: "?"
|
help: "?"
|
||||||
open_logs: "ctrl+g"
|
open_logs: "ctrl+g"
|
||||||
toggle_sidebar: "ctrl+b"
|
toggle_sidebar: "ctrl+b"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
type GlobalKeys struct {
|
type GlobalKeys struct {
|
||||||
Quit string `mapstructure:"quit"`
|
Quit string `mapstructure:"quit"`
|
||||||
|
Escape string `mapstructure:"escape"`
|
||||||
OpenLogs string `mapstructure:"open_logs"`
|
OpenLogs string `mapstructure:"open_logs"`
|
||||||
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
||||||
Help string `mapstructure:"help"`
|
Help string `mapstructure:"help"`
|
||||||
|
|||||||
+27
-8
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
|
roConn *sql.DB
|
||||||
path string
|
path string
|
||||||
dedupMu sync.Mutex
|
dedupMu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -24,9 +26,28 @@ func Open(path string) (*DB, error) {
|
|||||||
conn.SetMaxOpenConns(1)
|
conn.SetMaxOpenConns(1)
|
||||||
d := &DB{conn: conn, path: path}
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
roConn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
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
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +81,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
error_msg TEXT NOT NULL
|
error_msg TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
config_text TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS findings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
plugin_name TEXT NOT NULL,
|
plugin_name TEXT NOT NULL,
|
||||||
dedup_key TEXT NOT NULL,
|
dedup_key TEXT NOT NULL,
|
||||||
@@ -94,6 +110,7 @@ func (d *DB) Close() error {
|
|||||||
if d == nil {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
_ = d.roConn.Close()
|
||||||
return d.conn.Close()
|
return d.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +123,8 @@ func CountEntriesAt(path string) int {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var n int
|
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
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-14
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
e.ID, _ = res.LastInsertId()
|
var idErr error
|
||||||
|
e.ID, idErr = res.LastInsertId()
|
||||||
|
if idErr != nil {
|
||||||
|
log.Printf("db: LastInsertId: %v", idErr)
|
||||||
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,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 {
|
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
|
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
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
@@ -113,19 +122,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
|
|
||||||
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||||
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
||||||
// It opens a dedicated read-only connection so that any DML or DDL in the
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
// user-supplied expression is rejected by SQLite before it can execute.
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||||
roConn, err := sql.Open("sqlite", d.path)
|
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
|
||||||
if err != nil {
|
rows, err := d.roConn.Query(q)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer roConn.Close()
|
|
||||||
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
|
|
||||||
rows, err := roConn.Query(q)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -147,3 +148,18 @@ func (d *DB) DeleteAllEntries() error {
|
|||||||
_, err := d.conn.Exec(`DELETE FROM entries`)
|
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAllExceptFlagged deletes all unflagged entries. If there are no
|
||||||
|
// unflagged entries (only flagged ones remain), it deletes everything.
|
||||||
|
func (d *DB) DeleteAllExceptFlagged() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM entries WHERE flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
|||||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||||
rows, err := d.conn.Query(
|
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, created_at
|
||||||
FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
|
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
type PluginState struct {
|
|
||||||
Name string
|
|
||||||
Enabled bool
|
|
||||||
ConfigText string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
|
|
||||||
_, err := d.conn.Exec(
|
|
||||||
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
|
|
||||||
name, enabled, configText,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) LoadPluginStates() ([]PluginState, error) {
|
|
||||||
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PluginState
|
|
||||||
for rows.Next() {
|
|
||||||
var s PluginState
|
|
||||||
var enabled int
|
|
||||||
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.Enabled = enabled != 0
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +60,11 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
|||||||
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
||||||
return nil, err
|
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)
|
||||||
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewBroker() *Broker {
|
func NewBroker() *Broker {
|
||||||
|
size := config.Global.Intercept.QueueSize
|
||||||
|
if size <= 0 {
|
||||||
|
size = 64
|
||||||
|
}
|
||||||
b := &Broker{
|
b := &Broker{
|
||||||
Incoming: make(chan *PendingRequest, 64),
|
Incoming: make(chan *PendingRequest, size),
|
||||||
IncomingResponse: make(chan *PendingResponse, 64),
|
IncomingResponse: make(chan *PendingResponse, size),
|
||||||
}
|
}
|
||||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||||
return b
|
return b
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
|||||||
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
||||||
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
||||||
Copy: binding(cfg.Copy, "copy..."),
|
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"),
|
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
|
||||||
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
|
||||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||||
|
|||||||
+147
-4
@@ -3,14 +3,20 @@ package plugins
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
||||||
@@ -40,7 +46,7 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||||
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
||||||
msg := L.CheckString(1)
|
msg := L.CheckString(1)
|
||||||
log.Printf("[plugin:%s] %s", p.Name, msg)
|
log.Printf("plugin %s: %s", p.Name, msg)
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -79,7 +85,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return 0
|
||||||
}
|
}
|
||||||
_ = inserted
|
_ = inserted
|
||||||
@@ -175,6 +181,99 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
// p.mu is already held by the hook caller - do not lock again.
|
||||||
|
configText := p.ConfigText
|
||||||
|
if configText == "" {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
var data interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
lv := goToLuaValue(L, data)
|
||||||
|
if _, ok := lv.(*lua.LTable); !ok {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lv)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("send_request", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
method := L.CheckString(1)
|
||||||
|
rawURL := L.CheckString(2)
|
||||||
|
hdrs := L.OptTable(3, L.NewTable())
|
||||||
|
body := L.OptString(4, "")
|
||||||
|
opts := L.OptTable(5, L.NewTable())
|
||||||
|
|
||||||
|
insecure := false
|
||||||
|
if v, ok := L.GetField(opts, "insecure").(lua.LBool); ok {
|
||||||
|
insecure = bool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||||
|
}
|
||||||
|
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||||
|
if proxyURL, err := url.Parse(up); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
bodyReader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, rawURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
hdrs.ForEach(func(k, v lua.LValue) {
|
||||||
|
ks, kok := k.(lua.LString)
|
||||||
|
vs, vok := v.(lua.LString)
|
||||||
|
if kok && vok {
|
||||||
|
req.Header.Set(string(ks), string(vs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
result := L.NewTable()
|
||||||
|
L.SetField(result, "status_code", lua.LNumber(resp.StatusCode))
|
||||||
|
L.SetField(result, "body", lua.LString(string(respBody)))
|
||||||
|
respHeaders := L.NewTable()
|
||||||
|
for k, vals := range resp.Header {
|
||||||
|
L.SetField(respHeaders, k, lua.LString(strings.Join(vals, ", ")))
|
||||||
|
}
|
||||||
|
L.SetField(result, "headers", respHeaders)
|
||||||
|
|
||||||
|
L.Push(result)
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 2
|
||||||
|
}))
|
||||||
|
|
||||||
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
||||||
cmd := L.CheckString(1)
|
cmd := L.CheckString(1)
|
||||||
input := L.OptString(2, "")
|
input := L.OptString(2, "")
|
||||||
@@ -182,7 +281,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
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)
|
c.Stdin = strings.NewReader(input)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
@@ -201,6 +300,35 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func goToLuaValue(L *lua.LState, v interface{}) lua.LValue {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for k, v2 := range val {
|
||||||
|
L.SetField(t, k, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case []interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for i, v2 := range val {
|
||||||
|
L.RawSetInt(t, i+1, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return lua.LString(val)
|
||||||
|
case int:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case float64:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return lua.LTrue
|
||||||
|
}
|
||||||
|
return lua.LFalse
|
||||||
|
}
|
||||||
|
return lua.LNil
|
||||||
|
}
|
||||||
|
|
||||||
func luaTableString(t *lua.LTable, key string) string {
|
func luaTableString(t *lua.LTable, key string) string {
|
||||||
v := t.RawGetString(key)
|
v := t.RawGetString(key)
|
||||||
if s, ok := v.(lua.LString); ok {
|
if s, ok := v.(lua.LString); ok {
|
||||||
@@ -209,7 +337,7 @@ func luaTableString(t *lua.LTable, key string) string {
|
|||||||
return ""
|
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()
|
t := L.NewTable()
|
||||||
r := f.Request
|
r := f.Request
|
||||||
L.SetField(t, "method", lua.LString(r.Method))
|
L.SetField(t, "method", lua.LString(r.Method))
|
||||||
@@ -241,6 +369,21 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
|||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetField(t, "set_path", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
r.URL.Path = L.CheckString(2)
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
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 %s: set_url: %v", p.Name, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r.URL = parsed
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-58
@@ -13,14 +13,23 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GlobalDefault holds global defaults for a single plugin, read from config.yaml.
|
||||||
|
type GlobalDefault struct {
|
||||||
|
Enable *bool
|
||||||
|
Config interface{}
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins []*Plugin
|
plugins []*Plugin
|
||||||
|
|
||||||
db *db.DB
|
db *db.DB
|
||||||
broker *intercept.Broker
|
pluginsFile *PluginsFile
|
||||||
|
broker *intercept.Broker
|
||||||
|
globalDefaults map[string]GlobalDefault
|
||||||
|
|
||||||
Notifs chan PluginNotifMsg
|
Notifs chan PluginNotifMsg
|
||||||
Quit chan string
|
Quit chan string
|
||||||
@@ -43,6 +52,14 @@ func (m *Manager) SetDB(d *db.DB) {
|
|||||||
m.db = d
|
m.db = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
|
||||||
|
m.pluginsFile = pf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetGlobalDefaults(defaults map[string]GlobalDefault) {
|
||||||
|
m.globalDefaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) LoadFromDir(dir string) error {
|
func (m *Manager) LoadFromDir(dir string) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -52,17 +69,6 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var states map[string]db.PluginState
|
|
||||||
if m.db != nil {
|
|
||||||
list, err := m.db.LoadPluginStates()
|
|
||||||
if err == nil {
|
|
||||||
states = make(map[string]db.PluginState, len(list))
|
|
||||||
for _, s := range list {
|
|
||||||
states[s.Name] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||||
continue
|
continue
|
||||||
@@ -73,9 +79,23 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
log.Printf("plugin load error %s: %v", path, err)
|
log.Printf("plugin load error %s: %v", path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, ok := states[p.Name]; ok {
|
if m.pluginsFile != nil {
|
||||||
p.Enabled = s.Enabled
|
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
|
||||||
p.ConfigText = s.ConfigText
|
p.Enabled = enabled
|
||||||
|
p.ConfigText = configText
|
||||||
|
} else {
|
||||||
|
if def, ok := m.globalDefaults[p.ID]; ok {
|
||||||
|
if def.Enable != nil {
|
||||||
|
p.Enabled = *def.Enable
|
||||||
|
}
|
||||||
|
if def.Config != nil {
|
||||||
|
if raw, err := yaml.Marshal(def.Config); err == nil {
|
||||||
|
p.ConfigText = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.pluginsFile.register(p.ID, p.Enabled, p.ConfigText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.plugins = append(m.plugins, p)
|
m.plugins = append(m.plugins, p)
|
||||||
@@ -89,6 +109,7 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
|
|
||||||
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||||
p := &Plugin{
|
p := &Plugin{
|
||||||
|
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
hooks: make(map[string]HookConfig),
|
hooks: make(map[string]HookConfig),
|
||||||
@@ -109,7 +130,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Name = string(s)
|
p.Name = string(s)
|
||||||
}
|
}
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
|
p.Name = p.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
||||||
@@ -131,12 +152,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
"on_response": false,
|
"on_response": false,
|
||||||
"on_history_entry": false,
|
"on_history_entry": false,
|
||||||
}
|
}
|
||||||
// Fixed-sync hooks: always sync, not configurable.
|
|
||||||
fixedSyncHooks := map[string]struct{}{
|
|
||||||
"on_config": {},
|
|
||||||
"on_quit": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for hookName, defaultSync := range configurableHooks {
|
for hookName, defaultSync := range configurableHooks {
|
||||||
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
||||||
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
|
||||||
@@ -146,9 +161,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for hookName := range fixedSyncHooks {
|
for _, fixedSync := range []string{"on_config", "on_quit"} {
|
||||||
if p.L.GetGlobal(hookName) != lua.LNil {
|
if p.L.GetGlobal(fixedSync) != lua.LNil {
|
||||||
p.hooks[hookName] = HookConfig{Sync: true}
|
p.hooks[fixedSync] = HookConfig{Sync: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +178,11 @@ func (m *Manager) GetPlugins() []*Plugin {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TogglePlugin(name string) {
|
func (m *Manager) TogglePlugin(id string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -179,10 +194,11 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.Enabled = !found.Enabled
|
found.Enabled = !found.Enabled
|
||||||
enabled := found.Enabled
|
enabled := found.Enabled
|
||||||
configText := found.ConfigText
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return
|
return
|
||||||
@@ -194,8 +210,10 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
if ret == lua.LFalse {
|
if ret == lua.LFalse {
|
||||||
p.Enabled = false
|
p.Enabled = false
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,11 +240,11 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveConfig(name, configText string) {
|
func (m *Manager) SaveConfig(id, configText string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -237,39 +255,35 @@ func (m *Manager) SaveConfig(name, configText string) {
|
|||||||
}
|
}
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.ConfigText = configText
|
found.ConfigText = configText
|
||||||
enabled := found.Enabled
|
|
||||||
_, hasOnConfig := found.hooks["on_config"]
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setConfig(id, configText); err != nil {
|
||||||
|
log.Printf("plugin %s: save config: %v", id, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !hasOnConfig {
|
if _, ok := found.hooks["on_config"]; !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// on_config is always sync.
|
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
if _, err := callHook(found, "on_config"); err != nil {
|
||||||
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
log.Printf("plugin %s on_config: %v", id, err)
|
||||||
}
|
}
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunOnStart() {
|
func (m *Manager) RunOnStart() {
|
||||||
// on_config runs first, always sync, for every enabled plugin that has it.
|
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := p.hooks["on_config"]; !ok {
|
if _, ok := p.hooks["on_config"]; ok {
|
||||||
continue
|
p.mu.Lock()
|
||||||
|
if _, err := callHook(p, "on_config"); err != nil {
|
||||||
|
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
|
|
||||||
log.Printf("plugin %s on_config: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
// on_start runs after, sync or async depending on plugin config.
|
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -281,8 +295,10 @@ func (m *Manager) RunOnStart() {
|
|||||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
if ret == lua.LFalse {
|
if ret == lua.LFalse {
|
||||||
p.Enabled = false
|
p.Enabled = false
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,25 +395,25 @@ func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lu
|
|||||||
|
|
||||||
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
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) {
|
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
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 {
|
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||||
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
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) {
|
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||||
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
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)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pluginFileEntry struct {
|
||||||
|
Enable bool `yaml:"enable"`
|
||||||
|
Config interface{} `yaml:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginsFileData struct {
|
||||||
|
Plugins map[string]pluginFileEntry `yaml:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsFile struct {
|
||||||
|
path string
|
||||||
|
data pluginsFileData
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
|
||||||
|
path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml")
|
||||||
|
pf := &PluginsFile{
|
||||||
|
path: path,
|
||||||
|
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(dbDir, "plugins.yaml"), hardcoded filename
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &pf.data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pf.data.Plugins == nil {
|
||||||
|
pf.data.Plugins = make(map[string]pluginFileEntry)
|
||||||
|
}
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) save() error {
|
||||||
|
raw, err := yaml.Marshal(&pf.data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(pf.path, raw, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) {
|
||||||
|
e, ok := pf.data.Plugins[id]
|
||||||
|
if !ok {
|
||||||
|
return false, "", false
|
||||||
|
}
|
||||||
|
if e.Config == nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
raw, err := yaml.Marshal(e.Config)
|
||||||
|
if err != nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
return e.Enable, string(raw), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) register(id string, defaultEnabled bool, configText string) {
|
||||||
|
if _, ok := pf.data.Plugins[id]; !ok {
|
||||||
|
entry := pluginFileEntry{Enable: defaultEnabled}
|
||||||
|
if configText != "" {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &parsed); err == nil {
|
||||||
|
entry.Config = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pf.data.Plugins[id] = entry
|
||||||
|
if err := pf.save(); err != nil {
|
||||||
|
log.Printf("plugin %s: register save: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setEnabled(id string, enabled bool) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
e.Enable = enabled
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setConfig(id string, configText string) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
if configText == "" {
|
||||||
|
e.Config = nil
|
||||||
|
} else {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Config = parsed
|
||||||
|
}
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type HookConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
|
|||||||
hooks[k] = v
|
hooks[k] = v
|
||||||
}
|
}
|
||||||
return Info{
|
return Info{
|
||||||
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
FilePath: p.FilePath,
|
FilePath: p.FilePath,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
switch a.plugins.RunSyncOnRequest(f) {
|
switch a.plugins.RunSyncOnRequest(f) {
|
||||||
case intercept.Drop:
|
case intercept.Drop:
|
||||||
f.Response = dropResponse()
|
f.Response = dropResponse()
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
|
||||||
return
|
return
|
||||||
case intercept.Forward:
|
case intercept.Forward:
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
go a.plugins.RunAsyncOnRequest(f)
|
||||||
@@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||||
CaRootPath: caPath,
|
CaRootPath: caPath,
|
||||||
Upstream: cfg.UpstreamProxy,
|
Upstream: cfg.UpstreamProxy,
|
||||||
|
SslInsecure: cfg.SslInsecure,
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := goproxy.NewProxy(opts)
|
p, err := goproxy.NewProxy(opts)
|
||||||
@@ -133,7 +134,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
wantUser, wantPass := parts[0], parts[1]
|
wantUser, wantPass := parts[0], parts[1]
|
||||||
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||||
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||||
if !ok || user != wantUser || pass != wantPass {
|
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
|
||||||
|
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
|
||||||
|
if !ok || userOK&passOK != 1 {
|
||||||
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
||||||
return false, fmt.Errorf("invalid credentials")
|
return false, fmt.Errorf("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -1,64 +1,23 @@
|
|||||||
package style
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/paginator"
|
|
||||||
"charm.land/bubbles/v2/textarea"
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewViewport() viewport.Model {
|
|
||||||
vp := viewport.New()
|
|
||||||
vp.MouseWheelEnabled = false
|
|
||||||
return vp
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
func SeverityStyle(sev string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true)
|
base := lipgloss.NewStyle().Bold(true)
|
||||||
switch sev {
|
switch sev {
|
||||||
case "critical":
|
case "critical":
|
||||||
return base.Foreground(S.Error)
|
return base.Foreground(ilovetui.S.Error)
|
||||||
case "high":
|
case "high":
|
||||||
return base.Foreground(S.Warning)
|
return base.Foreground(ilovetui.S.Warning)
|
||||||
case "medium":
|
case "medium":
|
||||||
return base.Foreground(S.Primary)
|
return base.Foreground(ilovetui.S.Primary)
|
||||||
case "low":
|
case "low":
|
||||||
return base.Foreground(S.Success)
|
return base.Foreground(ilovetui.S.Success)
|
||||||
default:
|
default:
|
||||||
return base.Foreground(S.Subtle)
|
return base.Foreground(ilovetui.S.Subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,22 +25,12 @@ func StatusStyle(code, width int) lipgloss.Style {
|
|||||||
base := lipgloss.NewStyle().Bold(true).Width(width)
|
base := lipgloss.NewStyle().Bold(true).Width(width)
|
||||||
switch {
|
switch {
|
||||||
case code >= 500:
|
case code >= 500:
|
||||||
return base.Foreground(S.Error)
|
return base.Foreground(ilovetui.S.Error)
|
||||||
case code >= 400:
|
case code >= 400:
|
||||||
return base.Foreground(S.Warning)
|
return base.Foreground(ilovetui.S.Warning)
|
||||||
case code >= 300:
|
case code >= 300:
|
||||||
return base.Foreground(S.Primary)
|
return base.Foreground(ilovetui.S.Primary)
|
||||||
default:
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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> ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
-25
@@ -3,12 +3,13 @@ package style
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"image/color"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
|
"image/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Paint(c color.Color, s string) string {
|
func Paint(c color.Color, s string) string {
|
||||||
@@ -18,6 +19,7 @@ func Paint(c color.Color, s string) string {
|
|||||||
func HighlightHTTP(raw string) string {
|
func HighlightHTTP(raw string) string {
|
||||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
idx := strings.Index(raw, "\n\n")
|
idx := strings.Index(raw, "\n\n")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return highlightHeaders(raw)
|
return highlightHeaders(raw)
|
||||||
@@ -74,8 +76,8 @@ func highlightHeaders(raw string) string {
|
|||||||
} else if trimmed == "" {
|
} else if trimmed == "" {
|
||||||
out.WriteString(line)
|
out.WriteString(line)
|
||||||
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
|
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
|
||||||
out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
|
out.WriteString(Paint(ilovetui.S.Subtle, trimmed[:idx+2]))
|
||||||
out.WriteString(Paint(S.Text, trimmed[idx+2:]))
|
out.WriteString(Paint(ilovetui.S.Text, trimmed[idx+2:]))
|
||||||
} else {
|
} else {
|
||||||
out.WriteString(line)
|
out.WriteString(line)
|
||||||
}
|
}
|
||||||
@@ -94,16 +96,16 @@ func highlightStatusLine(line string) string {
|
|||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
||||||
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
|
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 {
|
if len(parts) == 3 {
|
||||||
result += " " + Paint(S.Subtle, parts[2])
|
result += " " + Paint(ilovetui.S.Subtle, parts[2])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
result := Paint(S.Subtle, parts[0]) + " "
|
result := Paint(ilovetui.S.Subtle, parts[0]) + " "
|
||||||
result += Paint(S.Warning, parts[1])
|
result += Paint(ilovetui.S.Warning, parts[1])
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
result += " " + Paint(S.MutedFg, parts[2])
|
result += " " + Paint(ilovetui.S.Muted, parts[2])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -133,9 +135,9 @@ func highlightJSON(s string) string {
|
|||||||
k++
|
k++
|
||||||
}
|
}
|
||||||
if k < n && s[k] == ':' {
|
if k < n && s[k] == ':' {
|
||||||
out.WriteString(Paint(S.Primary, str))
|
out.WriteString(Paint(ilovetui.S.Primary, str))
|
||||||
} else {
|
} else {
|
||||||
out.WriteString(Paint(S.Success, str))
|
out.WriteString(Paint(ilovetui.S.Success, str))
|
||||||
}
|
}
|
||||||
i = j
|
i = j
|
||||||
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
|
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
|
||||||
@@ -146,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] == '-') {
|
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
case i+4 <= n && s[i:i+4] == "true":
|
case i+4 <= n && s[i:i+4] == "true":
|
||||||
out.WriteString(Paint(S.Error, "true"))
|
out.WriteString(Paint(ilovetui.S.Error, "true"))
|
||||||
i += 4
|
i += 4
|
||||||
case i+5 <= n && s[i:i+5] == "false":
|
case i+5 <= n && s[i:i+5] == "false":
|
||||||
out.WriteString(Paint(S.Error, "false"))
|
out.WriteString(Paint(ilovetui.S.Error, "false"))
|
||||||
i += 5
|
i += 5
|
||||||
case i+4 <= n && s[i:i+4] == "null":
|
case i+4 <= n && s[i:i+4] == "null":
|
||||||
out.WriteString(Paint(S.Error, "null"))
|
out.WriteString(Paint(ilovetui.S.Error, "null"))
|
||||||
i += 4
|
i += 4
|
||||||
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
|
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
|
||||||
out.WriteString(Paint(S.Subtle, string(ch)))
|
out.WriteString(Paint(ilovetui.S.Subtle, string(ch)))
|
||||||
i++
|
i++
|
||||||
default:
|
default:
|
||||||
out.WriteByte(ch)
|
out.WriteByte(ch)
|
||||||
@@ -264,11 +266,11 @@ func highlightHTML(s string) string {
|
|||||||
if i+4 <= n && s[i:i+4] == "<!--" {
|
if i+4 <= n && s[i:i+4] == "<!--" {
|
||||||
end := strings.Index(s[i:], "-->")
|
end := strings.Index(s[i:], "-->")
|
||||||
if end == -1 {
|
if end == -1 {
|
||||||
out.WriteString(Paint(S.Subtle, s[i:]))
|
out.WriteString(Paint(ilovetui.S.Subtle, s[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
end = i + end + 3
|
end = i + end + 3
|
||||||
out.WriteString(Paint(S.Subtle, s[i:end]))
|
out.WriteString(Paint(ilovetui.S.Subtle, s[i:end]))
|
||||||
i = end
|
i = end
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -277,10 +279,10 @@ func highlightHTML(s string) string {
|
|||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Subtle, "<"))
|
out.WriteString(Paint(ilovetui.S.Subtle, "<"))
|
||||||
i++
|
i++
|
||||||
if i < n && (s[i] == '/' || s[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++
|
i++
|
||||||
}
|
}
|
||||||
j := i
|
j := i
|
||||||
@@ -288,7 +290,7 @@ func highlightHTML(s string) string {
|
|||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
if j > i {
|
if j > i {
|
||||||
out.WriteString(Paint(S.Primary, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Primary, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
for i < n && s[i] != '>' {
|
for i < n && s[i] != '>' {
|
||||||
@@ -298,10 +300,10 @@ func highlightHTML(s string) string {
|
|||||||
out.WriteByte(ch)
|
out.WriteByte(ch)
|
||||||
i++
|
i++
|
||||||
case ch == '/':
|
case ch == '/':
|
||||||
out.WriteString(Paint(S.Subtle, "/"))
|
out.WriteString(Paint(ilovetui.S.Subtle, "/"))
|
||||||
i++
|
i++
|
||||||
case ch == '=':
|
case ch == '=':
|
||||||
out.WriteString(Paint(S.Subtle, "="))
|
out.WriteString(Paint(ilovetui.S.Subtle, "="))
|
||||||
i++
|
i++
|
||||||
case ch == '"' || ch == '\'':
|
case ch == '"' || ch == '\'':
|
||||||
q := ch
|
q := ch
|
||||||
@@ -312,19 +314,19 @@ func highlightHTML(s string) string {
|
|||||||
if j < n {
|
if j < n {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Success, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Success, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
default:
|
default:
|
||||||
j = i
|
j = i
|
||||||
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
|
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i < n && s[i] == '>' {
|
if i < n && s[i] == '>' {
|
||||||
out.WriteString(Paint(S.Subtle, ">"))
|
out.WriteString(Paint(ilovetui.S.Subtle, ">"))
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-77
@@ -1,106 +1,48 @@
|
|||||||
package style
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Styles struct {
|
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
|
PanelEditing lipgloss.Style
|
||||||
|
|
||||||
PagerDotActive string
|
methodGet lipgloss.Style
|
||||||
PagerDotInactive string
|
methodPost lipgloss.Style
|
||||||
|
methodPutPatch lipgloss.Style
|
||||||
|
methodDelete lipgloss.Style
|
||||||
|
methodDefault lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
var S *Styles
|
var S *Styles
|
||||||
|
|
||||||
func Init(cfg *config.Config) {
|
func Init() {
|
||||||
c := cfg.TUI.Colors
|
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
S = &Styles{
|
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().
|
PanelEditing: lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(purple),
|
BorderForeground(ilovetui.S.Base0E),
|
||||||
|
|
||||||
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
|
methodGet: methodBase.Foreground(ilovetui.S.Success),
|
||||||
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
|
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 {
|
func (s *Styles) Method(method string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
|
||||||
switch method {
|
switch method {
|
||||||
case "GET":
|
case "GET":
|
||||||
return base.Foreground(s.Success)
|
return s.methodGet
|
||||||
case "POST":
|
case "POST":
|
||||||
return base.Foreground(s.Warning)
|
return s.methodPost
|
||||||
case "PUT", "PATCH":
|
case "PUT", "PATCH":
|
||||||
return base.Foreground(s.Primary)
|
return s.methodPutPatch
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return base.Foreground(s.Error)
|
return s.methodDelete
|
||||||
default:
|
default:
|
||||||
return base.Foreground(s.Text)
|
return s.methodDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -37,11 +36,9 @@ func tickCmd() tea.Cmd {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
|
||||||
|
|
||||||
var pageShortcuts = func() map[string]page {
|
var pageShortcuts = func() map[string]page {
|
||||||
m := make(map[string]page, len(sidebarEntries))
|
m := make(map[string]page, len(pageRegistry))
|
||||||
for i, e := range sidebarEntries {
|
for i, e := range pageRegistry {
|
||||||
m[strconv.Itoa(i+1)] = e.id
|
m[strconv.Itoa(i+1)] = e.id
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -55,6 +52,8 @@ type Model struct {
|
|||||||
database *db.DB
|
database *db.DB
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
pluginManager *plugins.Manager
|
pluginManager *plugins.Manager
|
||||||
|
fatalErr error
|
||||||
|
logFileErr error
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -96,8 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
|
|
||||||
d, err := db.Open(path)
|
d, err := db.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "db: %v\n", err)
|
m.fatalErr = err
|
||||||
os.Exit(1)
|
return m
|
||||||
}
|
}
|
||||||
m.database = d
|
m.database = d
|
||||||
broker.SetDB(d)
|
broker.SetDB(d)
|
||||||
@@ -106,6 +105,20 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.findingsPage.SetDB(d)
|
m.findingsPage.SetDB(d)
|
||||||
mgr.SetDB(d)
|
mgr.SetDB(d)
|
||||||
|
|
||||||
|
pf, err := plugins.OpenPluginsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugins file: %v", err)
|
||||||
|
}
|
||||||
|
mgr.SetPluginsFile(pf)
|
||||||
|
|
||||||
|
if len(cfg.Plugins) > 0 {
|
||||||
|
defaults := make(map[string]plugins.GlobalDefault, len(cfg.Plugins))
|
||||||
|
for id, gp := range cfg.Plugins {
|
||||||
|
defaults[id] = plugins.GlobalDefault{Enable: gp.Enable, Config: gp.Config}
|
||||||
|
}
|
||||||
|
mgr.SetGlobalDefaults(defaults)
|
||||||
|
}
|
||||||
|
|
||||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||||
log.Printf("plugins: %v", err)
|
log.Printf("plugins: %v", err)
|
||||||
@@ -116,14 +129,18 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.logFile = lf
|
m.logFile = lf
|
||||||
log.SetOutput(lf)
|
log.SetOutput(lf)
|
||||||
logrus.SetOutput(lf)
|
logrus.SetOutput(lf)
|
||||||
|
} else {
|
||||||
|
m.logFileErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) FatalErr() error { return m.fatalErr }
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
mgr := m.pluginManager
|
mgr := m.pluginManager
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
intercept.WaitForRequest(m.broker),
|
intercept.WaitForRequest(m.broker),
|
||||||
intercept.WaitForResponse(m.broker),
|
intercept.WaitForResponse(m.broker),
|
||||||
tickCmd(),
|
tickCmd(),
|
||||||
@@ -132,5 +149,16 @@ func (m Model) Init() tea.Cmd {
|
|||||||
plugins.WaitForQuit(mgr),
|
plugins.WaitForQuit(mgr),
|
||||||
findingsUI.RefreshCmd(m.database),
|
findingsUI.RefreshCmd(m.database),
|
||||||
func() tea.Msg { mgr.RunOnStart(); return nil },
|
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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type pageEntry struct {
|
|||||||
isEditing func(m *Model) bool
|
isEditing func(m *Model) bool
|
||||||
// resize propagates a new (w, h) to the page model.
|
// resize propagates a new (w, h) to the page model.
|
||||||
resize func(m *Model, w, h int)
|
resize func(m *Model, w, h int)
|
||||||
|
// hasUpdate reports whether the page has unseen updates.
|
||||||
|
hasUpdate func(m *Model) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageRegistry = []pageEntry{
|
var pageRegistry = []pageEntry{
|
||||||
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
|
|||||||
},
|
},
|
||||||
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
||||||
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageHistory,
|
id: pageHistory,
|
||||||
@@ -114,7 +117,8 @@ var pageRegistry = []pageEntry{
|
|||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageDocs,
|
id: pageDocs,
|
||||||
|
|||||||
+14
-11
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sidebarState string
|
type sidebarState string
|
||||||
@@ -42,7 +42,6 @@ func (m *Model) renderSidebar() string {
|
|||||||
if m.sidebarState == sidebarHidden {
|
if m.sidebarState == sidebarHidden {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
s := style.S
|
|
||||||
// content width inside bordered panel
|
// content width inside bordered panel
|
||||||
inner := m.getSidebarWidth() - 2
|
inner := m.getSidebarWidth() - 2
|
||||||
|
|
||||||
@@ -50,22 +49,26 @@ func (m *Model) renderSidebar() string {
|
|||||||
if m.sidebarState == sidebarCollapsed {
|
if m.sidebarState == sidebarCollapsed {
|
||||||
titleText = "SPLT"
|
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)
|
divider := strings.Repeat("─", inner)
|
||||||
|
|
||||||
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
|
badgeSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Bold(true)
|
||||||
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
|
badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||||
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
|
textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
textNormal := lipgloss.NewStyle().Foreground(s.Text)
|
textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||||
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
||||||
|
|
||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
for i, entry := range sidebarEntries {
|
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||||
|
|
||||||
|
for i, entry := range pageRegistry {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
badgeStyle, textStyle = badgeSelected, textSelected
|
badgeStyle, textStyle = badgeSelected, textSelected
|
||||||
|
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
|
||||||
|
badgeStyle = badgeUnread
|
||||||
}
|
}
|
||||||
icon := ""
|
icon := ""
|
||||||
if entry.icon != nil {
|
if entry.icon != nil {
|
||||||
@@ -91,13 +94,13 @@ func (m *Model) renderSidebar() string {
|
|||||||
}
|
}
|
||||||
parts := []string{
|
parts := []string{
|
||||||
title,
|
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,
|
parts = append(parts,
|
||||||
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
|
lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider),
|
||||||
items.String(),
|
items.String(),
|
||||||
)
|
)
|
||||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-21
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
||||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||||
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
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) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intercept.RequestArrivedMsg:
|
case intercept.RequestArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
m.intercept = updated.(interceptUI.Model)
|
m.intercept = updated.(interceptUI.Model)
|
||||||
|
if m.page == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
||||||
case intercept.ResponseArrivedMsg:
|
case intercept.ResponseArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
@@ -104,16 +107,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case proxyPkg.ErrMsg:
|
case proxyPkg.ErrMsg:
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
log.Printf("proxy error: %v", msg.Err)
|
log.Printf("proxy error: %v", msg.Err)
|
||||||
return m, tea.Batch(
|
m.fatalErr = msg.Err
|
||||||
func() tea.Msg {
|
return m, tea.Quit
|
||||||
return notificationsUI.NotificationMsg{
|
|
||||||
Title: "Proxy Error",
|
|
||||||
Body: msg.Err.Error(),
|
|
||||||
Kind: notificationsUI.KindError,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tea.Quit,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -129,6 +124,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case findingsUI.FindingsLoadedMsg:
|
case findingsUI.FindingsLoadedMsg:
|
||||||
updated, cmd := m.findingsPage.Update(msg)
|
updated, cmd := m.findingsPage.Update(msg)
|
||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
|
if m.page == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case replayUI.SendToReplayMsg:
|
case replayUI.SendToReplayMsg:
|
||||||
@@ -164,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
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() {
|
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
||||||
m.pluginManager.RunOnQuit()
|
m.pluginManager.RunOnQuit()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
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")
|
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() {
|
if !m.activeIsEditing() {
|
||||||
@@ -258,8 +247,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.history.RefreshCmd()
|
return m, m.history.RefreshCmd()
|
||||||
}
|
}
|
||||||
if p == pageFindings {
|
if p == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
return m, findingsUI.RefreshCmd(m.database)
|
return m, findingsUI.RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
if p == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -3,28 +3,31 @@ package app
|
|||||||
import (
|
import (
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/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 {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
v := tea.NewView("")
|
return m.newView("")
|
||||||
v.AltScreen = true
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normal := m.renderNormal()
|
normal := m.renderNormal()
|
||||||
|
|
||||||
if m.copyAs.IsOpen() {
|
if m.copyAs.IsOpen() {
|
||||||
v := tea.NewView(m.copyAs.View(normal))
|
v := m.newView(m.copyAs.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.copy.IsOpen() {
|
if m.copy.IsOpen() {
|
||||||
v := tea.NewView(m.copy.View(normal))
|
v := m.newView(m.copy.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,7 @@ func (m Model) View() tea.View {
|
|||||||
rendered = m.notifications.View(normal)
|
rendered = m.notifications.View(normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := tea.NewView(rendered)
|
v := m.newView(rendered)
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -52,5 +54,5 @@ func (m *Model) renderActivePage() string {
|
|||||||
return e.render(m)
|
return e.render(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return style.S.Faint.Render("Work in progress")
|
return ilovetui.S.Faint.Render("Work in progress")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,7 +14,6 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
@@ -48,20 +47,18 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
delegate := list.NewDefaultDelegate()
|
delegate := list.NewDefaultDelegate()
|
||||||
delegate.SetSpacing(0)
|
delegate.SetSpacing(0)
|
||||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(1)
|
Foreground(ilovetui.S.Muted).PaddingLeft(1)
|
||||||
|
|
||||||
l := list.New(allItems, delegate, popupW, 8)
|
l := list.New(allItems, delegate, popupW, 8)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
@@ -126,7 +123,7 @@ func (m Model) popupHeight() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) listHeight() 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 {
|
func (m Model) extract(id string) string {
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package copy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
|
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Model) View(background string) string {
|
func (m *Model) View(background string) string {
|
||||||
s := style.S
|
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||||
|
|
||||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
|
||||||
Render(" enter: copy • /: filter • esc: cancel")
|
Render(" enter: copy • /: filter • esc: cancel")
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
@@ -19,10 +17,10 @@ func (m *Model) View(background string) string {
|
|||||||
|
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary)
|
BorderForeground(ilovetui.S.Primary)
|
||||||
|
|
||||||
popupH := m.popupHeight()
|
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)
|
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type parsedRequest struct {
|
|||||||
path string
|
path string
|
||||||
host string
|
host string
|
||||||
scheme string
|
scheme string
|
||||||
headers []header // garder header{key, value} pour compat locale
|
headers []header
|
||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -12,7 +12,6 @@ const (
|
|||||||
popupH = 20
|
popupH = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
@@ -49,20 +48,18 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
delegate := list.NewDefaultDelegate()
|
delegate := list.NewDefaultDelegate()
|
||||||
delegate.SetSpacing(0)
|
delegate.SetSpacing(0)
|
||||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(1)
|
Foreground(ilovetui.S.Muted).PaddingLeft(1)
|
||||||
|
|
||||||
l := list.New(allFormats, delegate, popupW, 8)
|
l := list.New(allFormats, delegate, popupW, 8)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
@@ -117,5 +114,5 @@ func (m Model) popupHeight() int {
|
|||||||
|
|
||||||
// listHeight = panel content area - hint line (1)
|
// listHeight = panel content area - hint line (1)
|
||||||
func (m Model) listHeight() int {
|
func (m Model) listHeight() int {
|
||||||
return style.PanelContentH(m.popupHeight()) - 1
|
return ilovetui.ContentHeight(m.popupHeight()) - 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Model) View(background string) string {
|
func (m *Model) View(background string) string {
|
||||||
s := style.S
|
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||||
|
|
||||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
|
||||||
Render(" enter: copy • /: filter • esc: cancel")
|
Render(" enter: copy • /: filter • esc: cancel")
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
@@ -21,17 +19,15 @@ func (m *Model) View(background string) string {
|
|||||||
|
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary)
|
BorderForeground(ilovetui.S.Primary)
|
||||||
|
|
||||||
popupH := m.popupHeight()
|
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)
|
return OverlayCenter(background, popup, m.width, m.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OverlayCenter(bg, popup string, w, h int) string {
|
func OverlayCenter(bg, popup string, w, h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
stripped := ansi.Strip(bg)
|
stripped := ansi.Strip(bg)
|
||||||
rawLines := strings.Split(stripped, "\n")
|
rawLines := strings.Split(stripped, "\n")
|
||||||
bgRunes := make([][]rune, h)
|
bgRunes := make([][]rune, h)
|
||||||
@@ -67,7 +63,7 @@ func OverlayCenter(bg, popup string, w, h int) string {
|
|||||||
startX = 0
|
startX = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
|
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true)
|
||||||
|
|
||||||
result := make([]string, h)
|
result := make([]string, h)
|
||||||
for y := 0; y < h; y++ {
|
for y := 0; y < h; y++ {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +76,6 @@ func (m Model) View(background string) string {
|
|||||||
return background
|
return background
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
|
||||||
const popupW = 34
|
const popupW = 34
|
||||||
|
|
||||||
var popups []string
|
var popups []string
|
||||||
@@ -89,17 +88,17 @@ func (m Model) View(background string) string {
|
|||||||
var accent color.Color
|
var accent color.Color
|
||||||
switch n.kind {
|
switch n.kind {
|
||||||
case KindSuccess:
|
case KindSuccess:
|
||||||
accent = s.Success
|
accent = ilovetui.S.Success
|
||||||
case KindWarning:
|
case KindWarning:
|
||||||
accent = s.Warning
|
accent = ilovetui.S.Warning
|
||||||
case KindError:
|
case KindError:
|
||||||
accent = s.Error
|
accent = ilovetui.S.Error
|
||||||
default:
|
default:
|
||||||
accent = s.Primary
|
accent = ilovetui.S.Primary
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
|
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)
|
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
|
||||||
box := lipgloss.NewStyle().
|
box := lipgloss.NewStyle().
|
||||||
|
|||||||
+10
-12
@@ -8,6 +8,7 @@ import (
|
|||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"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]
|
segs[lo], segs[hi] = segs[hi], segs[lo]
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
boldErr := lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true)
|
||||||
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
|
boldOk := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true)
|
||||||
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
|
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||||
dim := lipgloss.NewStyle().Foreground(s.Subtle)
|
|
||||||
|
|
||||||
var lb, rb strings.Builder
|
var lb, rb strings.Builder
|
||||||
for _, seg := range segs {
|
for _, seg := range segs {
|
||||||
@@ -212,9 +212,9 @@ type Model struct {
|
|||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
return Model{
|
||||||
leftViewport: style.NewViewport(),
|
leftViewport: ilovetui.NewViewport(),
|
||||||
rightViewport: style.NewViewport(),
|
rightViewport: ilovetui.NewViewport(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ func (m *Model) recalcSizes() {
|
|||||||
rightInner = 0
|
rightInner = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
viewportH := style.PanelContentH(panelH)
|
viewportH := ilovetui.ContentHeight(panelH)
|
||||||
|
|
||||||
m.leftViewport.SetWidth(leftInner)
|
m.leftViewport.SetWidth(leftInner)
|
||||||
m.leftViewport.SetHeight(viewportH)
|
m.leftViewport.SetHeight(viewportH)
|
||||||
@@ -296,13 +296,11 @@ func hlLines(raw string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshViewports() {
|
func (m *Model) refreshViewports() {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
if m.left.raw == "" {
|
if m.left.raw == "" {
|
||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
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.leftViewport.SetContent(placeholder)
|
||||||
m.rightViewport.SetContent("")
|
m.rightViewport.SetContent("")
|
||||||
@@ -314,7 +312,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
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)
|
m.rightViewport.SetContent(placeholder)
|
||||||
return
|
return
|
||||||
|
|||||||
+11
-14
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
@@ -26,8 +27,6 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderPanels(panelH int) string {
|
func (m *Model) renderPanels(panelH int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
leftW := m.width / 2
|
leftW := m.width / 2
|
||||||
rightW := m.width - leftW
|
rightW := m.width - leftW
|
||||||
|
|
||||||
@@ -46,20 +45,20 @@ func (m *Model) renderPanels(panelH int) string {
|
|||||||
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
|
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
|
||||||
}
|
}
|
||||||
|
|
||||||
leftBorder := s.Panel
|
leftBorder := ilovetui.S.Panel
|
||||||
rightBorder := s.Panel
|
rightBorder := ilovetui.S.Panel
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case bothSlots:
|
case bothSlots:
|
||||||
leftBorder = s.PanelFocused
|
leftBorder = ilovetui.S.PanelFocused
|
||||||
rightBorder = s.PanelFocused
|
rightBorder = ilovetui.S.PanelFocused
|
||||||
case leftSlot:
|
case leftSlot:
|
||||||
leftBorder = s.PanelFocused
|
leftBorder = ilovetui.S.PanelFocused
|
||||||
case rightSlot:
|
case rightSlot:
|
||||||
rightBorder = s.PanelFocused
|
rightBorder = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
left := ilovetui.RenderWithTitle(leftBorder, leftTitle, ilovetui.ViewportView(&m.leftViewport), leftW, panelH)
|
||||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
right := ilovetui.RenderWithTitle(rightBorder, rightTitle, ilovetui.ViewportView(&m.rightViewport), rightW, panelH)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||||
}
|
}
|
||||||
@@ -69,12 +68,11 @@ func (m *Model) renderStatusBar() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderLeftLines(lines []diffLine) string {
|
func renderLeftLines(lines []diffLine) string {
|
||||||
s := style.S
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
switch l.kind {
|
switch l.kind {
|
||||||
case lineRemoved:
|
case lineRemoved:
|
||||||
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
|
sb.WriteString(style.Paint(ilovetui.S.Error, "- ") + l.text + "\n")
|
||||||
case lineAdded:
|
case lineAdded:
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
default:
|
default:
|
||||||
@@ -85,12 +83,11 @@ func renderLeftLines(lines []diffLine) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderRightLines(lines []diffLine) string {
|
func renderRightLines(lines []diffLine) string {
|
||||||
s := style.S
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
switch l.kind {
|
switch l.kind {
|
||||||
case lineAdded:
|
case lineAdded:
|
||||||
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
|
sb.WriteString(style.Paint(ilovetui.S.Success, "+ ") + l.text + "\n")
|
||||||
case lineRemoved:
|
case lineRemoved:
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func readDoc(name string) string {
|
func readDoc(name string) string {
|
||||||
@@ -25,10 +25,10 @@ func readDoc(name string) string {
|
|||||||
|
|
||||||
var contentMarkdown = strings.Join([]string{
|
var contentMarkdown = strings.Join([]string{
|
||||||
readDoc("main.md"),
|
readDoc("main.md"),
|
||||||
readDoc("legal-disclaimer.md"),
|
|
||||||
readDoc("basics.md"),
|
|
||||||
readDoc("proxy.md"),
|
readDoc("proxy.md"),
|
||||||
readDoc("certificate.md"),
|
readDoc("certificate.md"),
|
||||||
|
readDoc("legal-disclaimer.md"),
|
||||||
|
readDoc("basics.md"),
|
||||||
readDoc("history.md"),
|
readDoc("history.md"),
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
@@ -58,12 +58,12 @@ func New() Model {
|
|||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Prompt = "/"
|
ti.Prompt = "/"
|
||||||
s := ti.Styles()
|
s := ti.Styles()
|
||||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
|
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
ti.SetStyles(s)
|
ti.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
viewport: viewport.New(),
|
viewport: viewport.New(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
searchInput: ti,
|
searchInput: ti,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,8 @@ func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int)
|
|||||||
return ansiLine
|
return ansiLine
|
||||||
}
|
}
|
||||||
|
|
||||||
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
|
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg))
|
||||||
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
|
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text))
|
||||||
|
|
||||||
type injection struct {
|
type injection struct {
|
||||||
visPos int
|
visPos int
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func windowStyle() lipgloss.Style {
|
func windowStyle() lipgloss.Style {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(style.S.Subtle).
|
BorderForeground(ilovetui.S.Subtle).
|
||||||
Padding(0, 0)
|
Padding(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ func (e Model) View() tea.View {
|
|||||||
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
|
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
|
||||||
}
|
}
|
||||||
count := lipgloss.NewStyle().Padding(0, 1).
|
count := lipgloss.NewStyle().Padding(0, 1).
|
||||||
Foreground(style.S.MutedFg).
|
Foreground(ilovetui.S.Muted).
|
||||||
Render(countText)
|
Render(countText)
|
||||||
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func (m *Model) renderMarkdown() {
|
|||||||
|
|
||||||
width := m.viewport.Width() - 2
|
width := m.viewport.Width() - 2
|
||||||
renderer, _ := glamour.NewTermRenderer(
|
renderer, _ := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/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/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
database *db.DB
|
database *db.DB
|
||||||
findings []db.Finding
|
findings []db.Finding
|
||||||
cursor int
|
cursor int
|
||||||
|
hasUnread bool
|
||||||
|
knownCount int
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
bodyViewport viewport.Model
|
bodyViewport viewport.Model
|
||||||
@@ -37,15 +38,18 @@ type Model struct {
|
|||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
bodyViewport: style.NewViewport(),
|
bodyViewport: ilovetui.NewViewport(),
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
|
||||||
|
|
||||||
func (m *Model) CurrentMarkdown() string {
|
func (m *Model) CurrentMarkdown() string {
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -71,9 +75,9 @@ func (m *Model) recalcSizes() {
|
|||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
inner := 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 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -84,7 +88,7 @@ func (m *Model) recalcSizes() {
|
|||||||
m.pager.PerPage = 1
|
m.pager.PerPage = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ func (m *Model) refreshBodyScroll(reset bool) {
|
|||||||
|
|
||||||
func (m *Model) renderMarkdownCached(src string, width int) string {
|
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||||
if src == "" {
|
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)
|
tmpl, err := template.New("").Parse(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,7 +163,7 @@ func (m *Model) renderMarkdownCached(src string, width int) string {
|
|||||||
// Rebuild renderer if width changed or not yet built.
|
// Rebuild renderer if width changed or not yet built.
|
||||||
if m.renderer == nil || m.rendererWidth != width {
|
if m.renderer == nil || m.rendererWidth != width {
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
prevID = m.findings[m.cursor].ID
|
prevID = m.findings[m.cursor].ID
|
||||||
}
|
}
|
||||||
m.findings = msg.Findings
|
m.findings = msg.Findings
|
||||||
|
if len(m.findings) > m.knownCount {
|
||||||
|
m.hasUnread = true
|
||||||
|
}
|
||||||
if m.cursor >= len(m.findings) {
|
if m.cursor >= len(m.findings) {
|
||||||
m.cursor = max(0, len(m.findings)-1)
|
m.cursor = max(0, len(m.findings)-1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
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,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -27,44 +28,35 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderListPanel(w, h int) string {
|
func (m *Model) renderListPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.findings) > 0 {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.listViewport.View(),
|
m.listViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
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 {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
title := "Description"
|
title := "Description"
|
||||||
if len(m.findings) > 0 {
|
if len(m.findings) > 0 {
|
||||||
title = m.findings[m.cursor].Title
|
title = m.findings[m.cursor].Title
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(҂◡_◡) ᕤ", "no findings")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, f := range m.findings[start:end] {
|
for i, f := range m.findings[start:end] {
|
||||||
@@ -82,18 +74,18 @@ func (m *Model) renderList() string {
|
|||||||
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
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
|
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
|
||||||
bg.Width(1).Render(""),
|
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.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.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(titleW).Render(f.Title),
|
bg.Bold(true).Width(titleW).Render(f.Title),
|
||||||
)
|
)
|
||||||
@@ -104,9 +96,9 @@ func (m *Model) renderList() string {
|
|||||||
" ",
|
" ",
|
||||||
pluginStr,
|
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))
|
sb.WriteString(fmt.Sprintf("%s\n", line))
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"charm.land/bubbles/v2/textinput"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,10 +44,10 @@ func New() Model {
|
|||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Prompt = ""
|
ti.Prompt = ""
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
bodyViewport: style.NewViewport(),
|
bodyViewport: ilovetui.NewViewport(),
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
searchInput: ti,
|
searchInput: ti,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,14 +124,14 @@ func (m *Model) recalcSizes() {
|
|||||||
// 2 (padding) + 2 (prefix char + space)
|
// 2 (padding) + 2 (prefix char + space)
|
||||||
m.searchInput.SetWidth(m.width - 4)
|
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
|
inner := m.width - 2
|
||||||
if inner < 0 {
|
if inner < 0 {
|
||||||
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 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func (m *Model) recalcSizes() {
|
|||||||
m.pager.PerPage = 1
|
m.pager.PerPage = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package history
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
@@ -24,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
|
|||||||
if database == nil {
|
if database == nil {
|
||||||
return EntriesLoadedMsg{}
|
return EntriesLoadedMsg{}
|
||||||
}
|
}
|
||||||
entries, _ := database.ListEntries()
|
entries, err := database.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("history: load entries: %v", err)
|
||||||
|
}
|
||||||
return EntriesLoadedMsg{Entries: entries}
|
return EntriesLoadedMsg{Entries: entries}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +238,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, h.Flag):
|
case key.Matches(msg, h.Flag):
|
||||||
if len(m.entries) > 0 && m.database != nil {
|
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()
|
return m, m.RefreshCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +248,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
id := m.entries[m.cursor].ID
|
id := m.entries[m.cursor].ID
|
||||||
if m.database != nil {
|
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)
|
return m, LoadEntriesCmd(m.database)
|
||||||
}
|
}
|
||||||
@@ -249,11 +258,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, h.DeleteAll):
|
case key.Matches(msg, h.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
|
hasUnflagged := false
|
||||||
for _, e := range m.entries {
|
for _, e := range m.entries {
|
||||||
m.database.DeleteEntry(e.ID)
|
if !e.Flagged {
|
||||||
|
hasUnflagged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.entries {
|
||||||
|
if hasUnflagged && e.Flagged {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.database.DeleteEntry(e.ID); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.database.DeleteAllEntries()
|
if err := m.database.DeleteAllExceptFlagged(); err != nil {
|
||||||
|
log.Printf("history: delete all unflagged: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
@@ -335,7 +358,7 @@ func (m *Model) refreshBody() {
|
|||||||
}
|
}
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
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
|
return
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
+26
-35
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
@@ -17,7 +18,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
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,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -28,88 +29,78 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderListPanel(w, h int) string {
|
func (m *Model) renderListPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.entries) > 0 {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.listViewport.View(),
|
m.listViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
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 {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
title := icons.I.Request + "Request"
|
title := icons.I.Request + "Request"
|
||||||
if m.focusedPanel == panelResponse {
|
if m.focusedPanel == panelResponse {
|
||||||
title = icons.I.Response + "Response"
|
title = icons.I.Response + "Response"
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
s := style.S
|
|
||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
switch m.searchKind {
|
switch m.searchKind {
|
||||||
case searchKindFulltext:
|
case searchKindFulltext:
|
||||||
filterKey := keys.Keys.History.Filter.Help().Key
|
filterKey := keys.Keys.History.Filter.Help().Key
|
||||||
if m.searchAccepted {
|
if m.searchAccepted {
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
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 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:
|
case searchKindSQL:
|
||||||
sqlKey := keys.Keys.History.SqlQuery.Help().Key
|
sqlKey := keys.Keys.History.SqlQuery.Help().Key
|
||||||
if m.searchAccepted {
|
if m.searchAccepted {
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
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 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:
|
default:
|
||||||
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
|
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
|
||||||
if m.searchErr != "" {
|
if m.searchErr != "" {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
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 {
|
if len(m.entries) == 0 {
|
||||||
msg := util.CenterLines("(⌐■_■)", "no history yet")
|
msg := util.EmptyState(m.listViewport.Width(), "(⌐■_■)", "no history yet")
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
msg = util.CenterLines("ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(msg),
|
ilovetui.S.Faint.Render(msg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
|
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
|
|
||||||
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
||||||
@@ -121,7 +112,7 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
ts := e.Timestamp.Format("15:04:05")
|
ts := e.Timestamp.Format("15:04:05")
|
||||||
statusSt := style.StatusStyle(e.StatusCode, 3)
|
statusSt := style.StatusStyle(e.StatusCode, 3)
|
||||||
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
@@ -134,13 +125,13 @@ func (m *Model) renderList() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
bg.Foreground(s.Primary).Width(2).Render(flagStr),
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(1).Render(""),
|
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.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||||
)
|
)
|
||||||
@@ -155,14 +146,14 @@ func (m *Model) renderList() string {
|
|||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
flagSt.Width(2).Render(flagStr),
|
flagSt.Width(2).Render(flagStr),
|
||||||
s.Method(e.Method).Render(e.Method),
|
style.S.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
" ",
|
" ",
|
||||||
s.Faint.Width(10).Render(ts),
|
ilovetui.S.Faint.Width(10).Render(ts),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(e.Host),
|
ilovetui.S.Bold.Render(e.Host),
|
||||||
s.Faint.Render(e.Path),
|
ilovetui.S.Faint.Render(e.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
+13
-14
@@ -14,10 +14,10 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"charm.land/bubbles/v2/textinput"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,19 +88,18 @@ type homeDelegate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newHomeDelegate() homeDelegate {
|
func newHomeDelegate() homeDelegate {
|
||||||
s := style.S
|
|
||||||
leftBorder := lipgloss.Border{Left: "│"}
|
leftBorder := lipgloss.Border{Left: "│"}
|
||||||
return homeDelegate{
|
return homeDelegate{
|
||||||
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
|
normalTitle: lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(4),
|
||||||
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
|
normalDesc: lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true).PaddingLeft(4),
|
||||||
selectedTitle: lipgloss.NewStyle().
|
selectedTitle: lipgloss.NewStyle().
|
||||||
Border(leftBorder, false, false, false, true).
|
Border(leftBorder, false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(3),
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(3),
|
||||||
selectedDesc: lipgloss.NewStyle().
|
selectedDesc: lipgloss.NewStyle().
|
||||||
Border(leftBorder, false, false, false, true).
|
Border(leftBorder, false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(3),
|
Foreground(ilovetui.S.Muted).PaddingLeft(3),
|
||||||
filterMatch: lipgloss.NewStyle().Underline(true),
|
filterMatch: lipgloss.NewStyle().Underline(true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,14 +303,13 @@ func buildItems(projects []Project) []list.Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderHelpLine() string {
|
func (m Model) renderHelpLine() string {
|
||||||
s := style.S
|
|
||||||
k := keys.Keys.Home
|
k := keys.Keys.Home
|
||||||
fs := m.list.FilterState()
|
fs := m.list.FilterState()
|
||||||
|
|
||||||
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
|
kStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Inline(true)
|
||||||
dStyle := s.Faint.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 {
|
item := func(keyStr, desc string) string {
|
||||||
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
|
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
|
||||||
}
|
}
|
||||||
@@ -320,13 +318,14 @@ func (m Model) renderHelpLine() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
if fs == list.Filtering {
|
if fs == list.Filtering {
|
||||||
parts = append(parts, item("enter", "apply filter"))
|
parts = append(parts, item("enter", "apply filter"))
|
||||||
parts = append(parts, item("esc", "cancel"))
|
parts = append(parts, item(escKey, "cancel"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, item("↑/↓", "navigate"))
|
parts = append(parts, item("↑/↓", "navigate"))
|
||||||
if fs == list.FilterApplied {
|
if fs == list.FilterApplied {
|
||||||
parts = append(parts, item("esc", "clear filter"))
|
parts = append(parts, item(escKey, "clear filter"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, binding(k.Filter))
|
parts = append(parts, binding(k.Filter))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package home
|
|||||||
import (
|
import (
|
||||||
crypto "crypto/rand"
|
crypto "crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -72,7 +73,8 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
|||||||
return m, m.nameInput.Focus()
|
return m, m.nameInput.Focus()
|
||||||
case kindTemp:
|
case kindTemp:
|
||||||
dir := tempDir()
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -90,7 +92,9 @@ func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(item.path) // parent dir of data.db
|
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()
|
idx := m.list.GlobalIndex()
|
||||||
m.list.RemoveItem(idx)
|
m.list.RemoveItem(idx)
|
||||||
if idx > 0 && idx >= len(m.list.Items()) {
|
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.mode = modeSelect
|
||||||
m.nameInput.Blur()
|
m.nameInput.Blur()
|
||||||
dir := filepath.Join(m.projectDir, name)
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -138,23 +143,23 @@ func sanitizeName(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsValidProjectName(s string) bool {
|
func IsValidProjectName(s string) bool {
|
||||||
if s == "tmp" {
|
if s == "tmp" || s == "temp" || s == "temporary" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s != "" && s == sanitizeName(s)
|
return s != "" && s == sanitizeName(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Join(projectDir, name)
|
dir := filepath.Join(projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -171,9 +176,11 @@ func initProjectFiles(dir string) {
|
|||||||
for _, name := range []string{"data.db", "logs.log"} {
|
for _, name := range []string{"data.db", "logs.log"} {
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
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 {
|
if err == nil {
|
||||||
f.Close()
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
log.Printf("home: close project file: %v", cerr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/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"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
const inputPanelMaxW = 44
|
const inputPanelMaxW = 44
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
s := style.S
|
|
||||||
iw := m.innerW()
|
iw := m.innerW()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if m.height > teapotMinH {
|
if m.height > teapotMinH {
|
||||||
frames := teapot.TeapotFrames()
|
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(center(iw, frame))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("\n")
|
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("\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")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.mode == modeNaming {
|
if m.mode == modeNaming {
|
||||||
@@ -46,11 +46,11 @@ func (m Model) View() tea.View {
|
|||||||
v := tea.NewView(content)
|
v := tea.NewView(content)
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
|
v.WindowTitle = "Spilltea"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderNamingPanel() string {
|
func (m Model) renderNamingPanel() string {
|
||||||
s := style.S
|
|
||||||
iw := m.innerW()
|
iw := m.innerW()
|
||||||
|
|
||||||
panelW := inputPanelMaxW
|
panelW := inputPanelMaxW
|
||||||
@@ -63,15 +63,16 @@ func (m Model) renderNamingPanel() string {
|
|||||||
innerW := inputPanelInnerW(iw)
|
innerW := inputPanelInnerW(iw)
|
||||||
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
|
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().
|
panel := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
Width(panelW).
|
Width(panelW).
|
||||||
Render(label + "\n" + inputLine)
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString(center(iw, panel))
|
sb.WriteString(center(iw, panel))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
@@ -157,20 +158,20 @@ func (m *Model) listHalfWidths() (leftW, rightW int) {
|
|||||||
func (m *Model) recalcSizes() {
|
func (m *Model) recalcSizes() {
|
||||||
m.help.SetWidth(m.width - 2)
|
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
|
bodyInner := m.width - 2
|
||||||
if bodyInner < 0 {
|
if bodyInner < 0 {
|
||||||
bodyInner = 0
|
bodyInner = 0
|
||||||
}
|
}
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
|
|
||||||
m.textarea.SetWidth(bodyInner)
|
m.textarea.SetWidth(bodyInner)
|
||||||
m.textarea.SetHeight(bodyVH)
|
m.textarea.SetHeight(bodyVH)
|
||||||
m.bodyViewport.SetWidth(bodyInner)
|
m.bodyViewport.SetWidth(bodyInner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
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 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
package intercept
|
package intercept
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHelp() help.Model { return style.NewHelp() }
|
|
||||||
|
|
||||||
type interceptKeyMap struct{ width int }
|
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 {
|
func (interceptKeyMap) ShortHelp() []key.Binding {
|
||||||
ic := keys.Keys.Intercept
|
ic := keys.Keys.Intercept
|
||||||
i := icons.I
|
|
||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
iconBinding(ic.Forward, i.Forward),
|
ic.Forward,
|
||||||
iconBinding(ic.Drop, i.Drop),
|
ic.Drop,
|
||||||
iconBinding(ic.Edit, i.Edit),
|
ic.Edit,
|
||||||
keys.Keys.Global.Help,
|
keys.Keys.Global.Help,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textarea"
|
"charm.land/bubbles/v2/textarea"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type panel int
|
type panel int
|
||||||
@@ -30,6 +30,7 @@ type Model struct {
|
|||||||
|
|
||||||
editing bool
|
editing bool
|
||||||
interceptEnabled bool
|
interceptEnabled bool
|
||||||
|
hasUnread bool
|
||||||
pendingEdits map[*intercept.PendingRequest]string
|
pendingEdits map[*intercept.PendingRequest]string
|
||||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||||
|
|
||||||
@@ -47,14 +48,14 @@ type Model struct {
|
|||||||
|
|
||||||
func New(broker *intercept.Broker) Model {
|
func New(broker *intercept.Broker) Model {
|
||||||
cfg := config.Global
|
cfg := config.Global
|
||||||
ta := style.NewTextarea(false)
|
ta := ilovetui.NewTextarea(false)
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
|
||||||
lv := style.NewViewport()
|
lv := ilovetui.NewViewport()
|
||||||
rv := style.NewViewport()
|
rv := ilovetui.NewViewport()
|
||||||
bv := style.NewViewport()
|
bv := ilovetui.NewViewport()
|
||||||
p := style.NewPaginator()
|
p := ilovetui.NewPaginator()
|
||||||
rp := style.NewPaginator()
|
rp := ilovetui.NewPaginator()
|
||||||
|
|
||||||
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
|
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ func New(broker *intercept.Broker) Model {
|
|||||||
textarea: ta,
|
textarea: ta,
|
||||||
pager: p,
|
pager: p,
|
||||||
responsePager: rp,
|
responsePager: rp,
|
||||||
help: newHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
pendingEdits: make(map[*intercept.PendingRequest]string),
|
pendingEdits: make(map[*intercept.PendingRequest]string),
|
||||||
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
|
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,9 @@ func New(broker *intercept.Broker) Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false }
|
||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing }
|
||||||
|
|
||||||
func (m Model) IsResponseFocused() bool {
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
wasEmpty := len(m.queue) == 0
|
wasEmpty := len(m.queue) == 0
|
||||||
m.queue = append(m.queue, msg.Req)
|
m.queue = append(m.queue, msg.Req)
|
||||||
|
m.hasUnread = true
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
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
|
var listRow string
|
||||||
if m.captureResponse {
|
if m.captureResponse {
|
||||||
@@ -38,17 +39,15 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderListPanel(w, h int) string {
|
func (m *Model) renderListPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
|
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if focused {
|
if focused {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.queue) > 0 {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.listViewport.View(),
|
m.listViewport.View(),
|
||||||
@@ -57,46 +56,42 @@ func (m *Model) renderListPanel(w, h int) string {
|
|||||||
|
|
||||||
title := icons.I.Request + "Requests"
|
title := icons.I.Request + "Requests"
|
||||||
if !m.interceptEnabled {
|
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 {
|
func (m *Model) renderResponseListPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
focused := !m.editing && m.focusedPanel == panelResponses
|
focused := !m.editing && m.focusedPanel == panelResponses
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if focused {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.responseViewport.View(),
|
m.responseViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots),
|
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 {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
} else {
|
} else {
|
||||||
body = m.bodyViewport.View()
|
body = ilovetui.ViewportView(&m.bodyViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if m.editing {
|
if m.editing {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
title := icons.I.Detail + "Details"
|
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 {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -105,17 +100,10 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.queue) == 0 {
|
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))
|
||||||
start, end := m.pager.GetSliceBounds(len(m.queue))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, req := range m.queue[start:end] {
|
for i, req := range m.queue[start:end] {
|
||||||
@@ -127,7 +115,7 @@ func (m *Model) renderList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 7 + 2
|
const fixedW = 2 + 7 + 2
|
||||||
@@ -140,18 +128,18 @@ func (m *Model) renderList() string {
|
|||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
s.Method(r.Method).Background(selBg).Render(r.Method),
|
style.S.Method(r.Method).Background(selBg).Render(r.Method),
|
||||||
bg.Width(2).Render(""),
|
bg.Width(2).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
|
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
s.Method(r.Method).Render(r.Method),
|
style.S.Method(r.Method).Render(r.Method),
|
||||||
s.Faint.Render(" "),
|
ilovetui.S.Faint.Render(" "),
|
||||||
s.Bold.Render(r.URL.Host),
|
ilovetui.S.Bold.Render(r.URL.Host),
|
||||||
s.Faint.Render(path),
|
ilovetui.S.Faint.Render(path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
@@ -161,17 +149,10 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
func (m *Model) renderResponseList() string {
|
func (m *Model) renderResponseList() string {
|
||||||
if len(m.responseQueue) == 0 {
|
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))
|
||||||
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, resp := range m.responseQueue[start:end] {
|
for i, resp := range m.responseQueue[start:end] {
|
||||||
@@ -189,7 +170,7 @@ func (m *Model) renderResponseList() string {
|
|||||||
statusStr := fmt.Sprintf("%d", code)
|
statusStr := fmt.Sprintf("%d", code)
|
||||||
|
|
||||||
selected := globalIdx == m.responseCursor
|
selected := globalIdx == m.responseCursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
statusSt := style.StatusStyle(code, 7)
|
statusSt := style.StatusStyle(code, 7)
|
||||||
|
|
||||||
@@ -204,7 +185,7 @@ func (m *Model) renderResponseList() string {
|
|||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
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),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(2).Render(""),
|
bg.Width(2).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
|
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
|
||||||
@@ -213,9 +194,9 @@ func (m *Model) renderResponseList() string {
|
|||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
s.Faint.Render(" "),
|
ilovetui.S.Faint.Render(" "),
|
||||||
s.Bold.Render(f.Request.URL.Host),
|
ilovetui.S.Bold.Render(f.Request.URL.Host),
|
||||||
s.Faint.Render(path),
|
ilovetui.S.Faint.Render(path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"charm.land/bubbles/v2/textinput"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/plugins"
|
"github.com/anotherhadi/spilltea/internal/plugins"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -36,7 +36,7 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(mgr *plugins.Manager) Model {
|
func New(mgr *plugins.Manager) Model {
|
||||||
ta := style.NewTextarea(true)
|
ta := ilovetui.NewTextarea(true)
|
||||||
ta.Placeholder = "plugin configuration..."
|
ta.Placeholder = "plugin configuration..."
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@ func New(mgr *plugins.Manager) Model {
|
|||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
manager: mgr,
|
manager: mgr,
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
detailViewport: style.NewViewport(),
|
detailViewport: ilovetui.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,14 +70,14 @@ func (m *Model) recalcSizes() {
|
|||||||
}
|
}
|
||||||
m.help.SetWidth(m.width - 2)
|
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
|
inner := m.width - 2
|
||||||
if inner < 0 {
|
if inner < 0 {
|
||||||
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 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func (m *Model) recalcSizes() {
|
|||||||
|
|
||||||
m.filterInput.SetWidth(inner - 2)
|
m.filterInput.SetWidth(inner - 2)
|
||||||
|
|
||||||
detailContentH := style.PanelContentH(detailH)
|
detailContentH := ilovetui.ContentHeight(detailH)
|
||||||
const headerH = 2
|
const headerH = 2
|
||||||
const configFixedH = 2 // blank line + label line
|
const configFixedH = 2 // blank line + label line
|
||||||
textareaH := max(3, detailContentH/3)
|
textareaH := max(3, detailContentH/3)
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
val := m.textarea.Value()
|
val := m.textarea.Value()
|
||||||
m.manager.SaveConfig(info.Name, val)
|
m.manager.SaveConfig(info.ID, val)
|
||||||
// Update cached info.
|
// Update cached info.
|
||||||
m.filtered[m.cursor].ConfigText = val
|
m.filtered[m.cursor].ConfigText = val
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].ConfigText = val
|
m.items[i].ConfigText = val
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -107,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, pk.Toggle):
|
case key.Matches(msg, pk.Toggle):
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
m.manager.TogglePlugin(info.Name)
|
m.manager.TogglePlugin(info.ID)
|
||||||
m.filtered[m.cursor].Enabled = !info.Enabled
|
m.filtered[m.cursor].Enabled = !info.Enabled
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].Enabled = !info.Enabled
|
m.items[i].Enabled = !info.Enabled
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-43
@@ -7,19 +7,18 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/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/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 || m.manager == nil {
|
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,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -30,36 +29,34 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderListPanel(w, h int) string {
|
func (m *Model) renderListPanel(w, h int) string {
|
||||||
s := style.S
|
panelStyle := ilovetui.S.PanelFocused
|
||||||
panelStyle := s.PanelFocused
|
|
||||||
if m.editing {
|
if m.editing {
|
||||||
panelStyle = s.Panel
|
panelStyle = ilovetui.S.Panel
|
||||||
}
|
}
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.filtered) > 0 {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.listViewport.View(),
|
m.listViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
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 {
|
func (m *Model) renderDetailPanel(h int) string {
|
||||||
s := style.S
|
panelStyle := ilovetui.S.Panel
|
||||||
panelStyle := s.Panel
|
|
||||||
if m.editing {
|
if m.editing {
|
||||||
panelStyle = s.PanelFocused
|
panelStyle = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
info, ok := m.selected()
|
info, ok := m.selected()
|
||||||
if !ok {
|
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 {
|
if info.Enabled {
|
||||||
statusSt = lipgloss.NewStyle().Foreground(s.Success)
|
statusSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||||
}
|
}
|
||||||
status := "disabled"
|
status := "disabled"
|
||||||
if info.Enabled {
|
if info.Enabled {
|
||||||
@@ -69,26 +66,26 @@ func (m *Model) renderDetailPanel(h int) string {
|
|||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
header := pad.Render(
|
header := pad.Render(
|
||||||
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
ilovetui.S.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
||||||
s.Faint.Render(filepath.Base(info.FilePath)),
|
ilovetui.S.Faint.Render(filepath.Base(info.FilePath)),
|
||||||
)
|
)
|
||||||
|
|
||||||
parts := []string{header, m.detailViewport.View()}
|
parts := []string{header, ilovetui.ViewportView(&m.detailViewport)}
|
||||||
|
|
||||||
if m.hasConfig() {
|
if m.hasConfig() {
|
||||||
var configLabel string
|
var configLabel string
|
||||||
if m.editing {
|
if m.editing {
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
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 {
|
} else {
|
||||||
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
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()))
|
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
|
||||||
}
|
}
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
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 {
|
func renderPluginDescription(desc string, width int) string {
|
||||||
@@ -100,7 +97,7 @@ func renderPluginDescription(desc string, width int) string {
|
|||||||
desc = strings.Join(lines, "\n")
|
desc = strings.Join(lines, "\n")
|
||||||
|
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -114,52 +111,44 @@ func renderPluginDescription(desc string, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
s := style.S
|
|
||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
||||||
if m.filtering {
|
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 != "" {
|
if m.filter != "" {
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
|
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 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}))
|
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
|
||||||
if len(m.filtered) == 0 {
|
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 != "" {
|
if m.filter != "" {
|
||||||
msg = util.CenterLines("= _ =", "no results")
|
msg = util.EmptyState(m.listViewport.Width(), "= _ =", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(msg),
|
ilovetui.S.Faint.Render(msg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, p := range m.filtered[start:end] {
|
for i, p := range m.filtered[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
|
|
||||||
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
|
enabledSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||||
enabledStr := "off"
|
enabledStr := "off"
|
||||||
if p.Enabled {
|
if p.Enabled {
|
||||||
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
|
enabledSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||||
enabledStr = "on "
|
enabledStr = "on "
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,10 +161,10 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
|
enabledSt.Background(ilovetui.S.Selection).Width(3).Render(enabledStr),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(nameW).Render(p.Name),
|
bg.Bold(true).Width(nameW).Render(p.Name),
|
||||||
)
|
)
|
||||||
@@ -184,7 +173,7 @@ func (m *Model) renderList() string {
|
|||||||
" ",
|
" ",
|
||||||
enabledSt.Width(3).Render(enabledStr),
|
enabledSt.Width(3).Render(enabledStr),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(p.Name),
|
ilovetui.S.Bold.Render(p.Name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
+10
-10
@@ -9,9 +9,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textarea"
|
"charm.land/bubbles/v2/textarea"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendToReplayMsg struct {
|
type SendToReplayMsg struct {
|
||||||
@@ -61,15 +61,15 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
ta := style.NewTextarea(false)
|
ta := ilovetui.NewTextarea(false)
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
requestViewport: style.NewViewport(),
|
requestViewport: ilovetui.NewViewport(),
|
||||||
responseViewport: style.NewViewport(),
|
responseViewport: ilovetui.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,13 +149,13 @@ func (m *Model) SetSize(w, h int) {
|
|||||||
func (m *Model) recalcSizes() {
|
func (m *Model) recalcSizes() {
|
||||||
m.help.SetWidth(m.width - 2)
|
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)
|
||||||
|
|
||||||
listInner := m.width - 2
|
listInner := m.width - 2
|
||||||
if listInner < 0 {
|
if listInner < 0 {
|
||||||
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 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ func (m *Model) recalcSizes() {
|
|||||||
if rightInner < 0 {
|
if rightInner < 0 {
|
||||||
rightInner = 0
|
rightInner = 0
|
||||||
}
|
}
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
|
|
||||||
m.requestViewport.SetWidth(leftInner)
|
m.requestViewport.SetWidth(leftInner)
|
||||||
m.requestViewport.SetHeight(bodyVH)
|
m.requestViewport.SetHeight(bodyVH)
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import (
|
|||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"compress/zlib"
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,12 +21,13 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
|
||||||
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +84,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
e.ResponseRaw = "Error: " + msg.err.Error()
|
e.ResponseRaw = "Error: " + msg.err.Error()
|
||||||
}
|
}
|
||||||
if m.database != nil && e.DBID != 0 {
|
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.refreshListViewport()
|
m.refreshListViewport()
|
||||||
@@ -95,24 +103,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
case tea.MouseWheelUp:
|
case tea.MouseWheelUp:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(-1)
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelDown:
|
case tea.MouseWheelDown:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.scrollFocusedViewportVertical(1)
|
m.scrollFocusedViewportVertical(1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelLeft:
|
case tea.MouseWheelLeft:
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
case tea.MouseWheelRight:
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, r.EditExt):
|
case key.Matches(msg, r.EditExt):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m, util.OpenExternalEditorView(m.entries[m.cursor].ResponseRaw)
|
||||||
|
}
|
||||||
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
|
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +219,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
e := m.entries[m.cursor]
|
e := m.entries[m.cursor]
|
||||||
if m.database != nil && e.DBID != 0 {
|
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:]...)
|
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
|
||||||
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
||||||
@@ -225,7 +234,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, r.DeleteAll):
|
case key.Matches(msg, r.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteAllReplayEntries()
|
if err := m.database.DeleteAllReplayEntries(); err != nil {
|
||||||
|
log.Printf("replay: delete all entries: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.entries = nil
|
m.entries = nil
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
@@ -316,6 +327,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) {
|
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
||||||
vp := m.focusedViewport()
|
vp := m.focusedViewport()
|
||||||
vp.SetYOffset(vp.YOffset() + delta)
|
vp.SetYOffset(vp.YOffset() + delta)
|
||||||
@@ -347,11 +368,11 @@ func (m *Model) refreshBody() {
|
|||||||
m.requestViewport.SetXOffset(0)
|
m.requestViewport.SetXOffset(0)
|
||||||
|
|
||||||
if e.Sending {
|
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 != "" {
|
} else if e.ResponseRaw != "" {
|
||||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||||
} else {
|
} 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.SetYOffset(0)
|
||||||
m.responseViewport.SetXOffset(0)
|
m.responseViewport.SetXOffset(0)
|
||||||
@@ -392,11 +413,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
}
|
}
|
||||||
req.Header = headers
|
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{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: transport,
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
||||||
},
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
|
|||||||
+25
-34
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
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()
|
leftW, rightW := m.bodyHalfWidths()
|
||||||
|
|
||||||
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
@@ -33,45 +34,42 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderListPanel(w, h int) string {
|
func (m *Model) renderListPanel(w, h int) string {
|
||||||
s := style.S
|
panelStyle := ilovetui.S.Panel
|
||||||
panelStyle := s.Panel
|
|
||||||
if !m.editing && m.focusedPanel == panelList {
|
if !m.editing && m.focusedPanel == panelList {
|
||||||
panelStyle = s.PanelFocused
|
panelStyle = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
var dots string
|
var dots string
|
||||||
if len(m.entries) > 0 {
|
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,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.listViewport.View(),
|
m.listViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
|
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 {
|
func (m *Model) renderRequestPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
var body string
|
var body string
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
} else {
|
} else {
|
||||||
body = m.requestViewport.View()
|
body = ilovetui.ViewportView(&m.requestViewport)
|
||||||
if m.focusedPanel == panelRequest {
|
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 {
|
func (m *Model) renderResponsePanel(w, h int) string {
|
||||||
s := style.S
|
border := ilovetui.S.Panel
|
||||||
border := s.Panel
|
|
||||||
if !m.editing && m.focusedPanel == panelResponse {
|
if !m.editing && m.focusedPanel == panelResponse {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h)
|
return ilovetui.RenderWithTitle(border, icons.I.Response+"Response", ilovetui.ViewportView(&m.responseViewport), w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -83,24 +81,17 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
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))
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 7 + 1 + 3 + 1
|
const fixedW = 2 + 7 + 1 + 3 + 1
|
||||||
@@ -115,8 +106,8 @@ func (m *Model) renderList() string {
|
|||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
@@ -125,12 +116,12 @@ func (m *Model) renderList() string {
|
|||||||
} else {
|
} else {
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
s.Method(e.Method).Render(e.Method),
|
style.S.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(e.Host),
|
ilovetui.S.Bold.Render(e.Host),
|
||||||
s.Faint.Render(e.Path),
|
ilovetui.S.Faint.Render(e.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
@@ -142,11 +133,11 @@ func entryStatus(e Entry) (string, lipgloss.Style) {
|
|||||||
base := lipgloss.NewStyle().Bold(true).Width(3)
|
base := lipgloss.NewStyle().Bold(true).Width(3)
|
||||||
switch {
|
switch {
|
||||||
case e.Sending:
|
case e.Sending:
|
||||||
return "···", base.Foreground(style.S.Subtle)
|
return "···", base.Foreground(ilovetui.S.Subtle)
|
||||||
case e.Err != nil:
|
case e.Err != nil:
|
||||||
return "ERR", base.Foreground(style.S.Error)
|
return "ERR", base.Foreground(ilovetui.S.Error)
|
||||||
case e.StatusCode == 0:
|
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)
|
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -1,6 +1,7 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ type EditorFinishedMsg struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenExternalEditor(content string) tea.Cmd {
|
func ResolveEditor() string {
|
||||||
editor := config.Global.App.ExternalEditor
|
editor := config.Global.App.ExternalEditor
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = os.Getenv("EDITOR")
|
editor = os.Getenv("EDITOR")
|
||||||
@@ -22,22 +23,43 @@ func OpenExternalEditor(content string) tea.Cmd {
|
|||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
|
||||||
|
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("editor: create temp file: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
_, _ = f.WriteString(content)
|
if _, err := f.WriteString(content); err != nil {
|
||||||
f.Close()
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
return tea.ExecProcess(exec.Command(editor, 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)
|
defer os.Remove(tmpPath)
|
||||||
|
return callback(tmpPath, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenExternalEditor(content string) tea.Cmd {
|
||||||
|
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EditorFinishedMsg{Err: err}
|
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 {
|
if readErr != nil {
|
||||||
return EditorFinishedMsg{Err: readErr}
|
return EditorFinishedMsg{Err: readErr}
|
||||||
}
|
}
|
||||||
return EditorFinishedMsg{Content: string(data)}
|
return EditorFinishedMsg{Content: string(data)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func OpenExternalEditorView(content string) tea.Cmd {
|
||||||
|
return openWithEditor(content, func(_ string, _ error) tea.Msg {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package util
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/paginator"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,14 @@ func Truncate(s string, max int) string {
|
|||||||
return s[:max-1] + "…"
|
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.
|
// CenterLines centers each line horizontally relative to the longest one.
|
||||||
func CenterLines(lines ...string) string {
|
func CenterLines(lines ...string) string {
|
||||||
maxWidth := 0
|
maxWidth := 0
|
||||||
@@ -28,6 +37,18 @@ func CenterLines(lines ...string) string {
|
|||||||
return strings.Join(centered, "\n")
|
return strings.Join(centered, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PageBounds returns clamped start/end indices for rendering a paginated list.
|
||||||
|
func PageBounds(p paginator.Model, total int) (start, end int) {
|
||||||
|
start, end = p.GetSliceBounds(total)
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if end < start {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// InferScheme returns "http" for port 80, "https" otherwise.
|
// InferScheme returns "http" for port 80, "https" otherwise.
|
||||||
func InferScheme(host string) string {
|
func InferScheme(host string) string {
|
||||||
if strings.HasSuffix(host, ":80") {
|
if strings.HasSuffix(host, ":80") {
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
pre-commit:
|
|
||||||
piped: true
|
|
||||||
commands:
|
|
||||||
check-vendor-hash:
|
|
||||||
glob: "{go.mod,go.sum}"
|
|
||||||
run: .github/scripts/check-vendor-hash.sh
|
|
||||||
stage_fixed: true
|
|
||||||
inject-exec-basics:
|
|
||||||
glob: "{docs/basics.md,cmd/**}"
|
|
||||||
run: python3 .github/scripts/inject-exec.py docs/basics.md
|
|
||||||
stage_fixed: true
|
|
||||||
inject-exec:
|
|
||||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
|
||||||
run: python3 .github/scripts/inject-exec.py README.md
|
|
||||||
stage_fixed: true
|
|
||||||
toc:
|
|
||||||
glob: "{README.md,docs/basics.md,cmd/**}"
|
|
||||||
run: doctoc --notitle README.md
|
|
||||||
stage_fixed: true
|
|
||||||
@@ -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
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
schema = 3
|
||||||
|
|
||||||
|
[mod]
|
||||||
|
[mod.'charm.land/bubbles/v2']
|
||||||
|
version = 'v2.1.0'
|
||||||
|
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
|
||||||
|
|
||||||
|
[mod.'charm.land/bubbletea/v2']
|
||||||
|
version = 'v2.0.6'
|
||||||
|
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
|
||||||
|
|
||||||
|
[mod.'charm.land/glamour/v2']
|
||||||
|
version = 'v2.0.0'
|
||||||
|
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
|
||||||
|
|
||||||
|
[mod.'charm.land/lipgloss/v2']
|
||||||
|
version = 'v2.0.3'
|
||||||
|
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
|
||||||
|
|
||||||
|
[mod.'github.com/alecthomas/chroma/v2']
|
||||||
|
version = 'v2.24.1'
|
||||||
|
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
|
||||||
|
|
||||||
|
[mod.'github.com/andybalholm/brotli']
|
||||||
|
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='
|
||||||
|
|
||||||
|
[mod.'github.com/aymerick/douceur']
|
||||||
|
version = 'v0.2.0'
|
||||||
|
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/colorprofile']
|
||||||
|
version = 'v0.4.3'
|
||||||
|
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/ultraviolet']
|
||||||
|
version = 'v0.0.0-20260511121909-c840852527f3'
|
||||||
|
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/ansi']
|
||||||
|
version = 'v0.11.7'
|
||||||
|
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/exp/slice']
|
||||||
|
version = 'v0.0.0-20260517005351-920740d613be'
|
||||||
|
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/term']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/termios']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/windows']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/displaywidth']
|
||||||
|
version = 'v0.11.0'
|
||||||
|
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/uax29/v2']
|
||||||
|
version = 'v2.7.0'
|
||||||
|
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
|
||||||
|
|
||||||
|
[mod.'github.com/dlclark/regexp2']
|
||||||
|
version = 'v1.12.0'
|
||||||
|
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
|
||||||
|
|
||||||
|
[mod.'github.com/dustin/go-humanize']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
|
||||||
|
|
||||||
|
[mod.'github.com/fsnotify/fsnotify']
|
||||||
|
version = 'v1.10.1'
|
||||||
|
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
|
||||||
|
|
||||||
|
[mod.'github.com/go-viper/mapstructure/v2']
|
||||||
|
version = 'v2.5.0'
|
||||||
|
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
|
||||||
|
|
||||||
|
[mod.'github.com/golang/groupcache']
|
||||||
|
version = 'v0.0.0-20210331224755-41bb18bfe9da'
|
||||||
|
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
|
||||||
|
|
||||||
|
[mod.'github.com/google/uuid']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/css']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/websocket']
|
||||||
|
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='
|
||||||
|
|
||||||
|
[mod.'github.com/lqqyt2423/go-mitmproxy']
|
||||||
|
version = 'v1.8.11'
|
||||||
|
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
|
||||||
|
|
||||||
|
[mod.'github.com/lucasb-eyer/go-colorful']
|
||||||
|
version = 'v1.4.0'
|
||||||
|
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-isatty']
|
||||||
|
version = 'v0.0.22'
|
||||||
|
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-runewidth']
|
||||||
|
version = 'v0.0.23'
|
||||||
|
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
|
||||||
|
|
||||||
|
[mod.'github.com/microcosm-cc/bluemonday']
|
||||||
|
version = 'v1.0.27'
|
||||||
|
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
|
||||||
|
|
||||||
|
[mod.'github.com/muesli/cancelreader']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
|
||||||
|
|
||||||
|
[mod.'github.com/ncruces/go-strftime']
|
||||||
|
version = 'v1.0.0'
|
||||||
|
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
|
||||||
|
|
||||||
|
[mod.'github.com/pelletier/go-toml/v2']
|
||||||
|
version = 'v2.3.1'
|
||||||
|
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
|
||||||
|
|
||||||
|
[mod.'github.com/remyoudompheng/bigfft']
|
||||||
|
version = 'v0.0.0-20230129092748-24d4a6f8daec'
|
||||||
|
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
|
||||||
|
|
||||||
|
[mod.'github.com/rivo/uniseg']
|
||||||
|
version = 'v0.4.7'
|
||||||
|
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
|
||||||
|
|
||||||
|
[mod.'github.com/sagikazarmark/locafero']
|
||||||
|
version = 'v0.12.0'
|
||||||
|
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
|
||||||
|
|
||||||
|
[mod.'github.com/sahilm/fuzzy']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
|
||||||
|
|
||||||
|
[mod.'github.com/satori/go.uuid']
|
||||||
|
version = 'v1.2.0'
|
||||||
|
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
|
||||||
|
|
||||||
|
[mod.'github.com/sirupsen/logrus']
|
||||||
|
version = 'v1.9.4'
|
||||||
|
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/afero']
|
||||||
|
version = 'v1.15.0'
|
||||||
|
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cast']
|
||||||
|
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='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/viper']
|
||||||
|
version = 'v1.21.0'
|
||||||
|
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
|
||||||
|
|
||||||
|
[mod.'github.com/subosito/gotenv']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
|
||||||
|
|
||||||
|
[mod.'github.com/xo/terminfo']
|
||||||
|
version = 'v0.0.0-20220910002029-abceb7e1c41e'
|
||||||
|
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark']
|
||||||
|
version = 'v1.8.2'
|
||||||
|
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark-emoji']
|
||||||
|
version = 'v1.0.6'
|
||||||
|
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/gopher-lua']
|
||||||
|
version = 'v1.1.2'
|
||||||
|
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
|
||||||
|
|
||||||
|
[mod.'go.uber.org/atomic']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
|
||||||
|
|
||||||
|
[mod.'go.yaml.in/yaml/v3']
|
||||||
|
version = 'v3.0.4'
|
||||||
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/net']
|
||||||
|
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.45.0'
|
||||||
|
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/text']
|
||||||
|
version = 'v0.37.0'
|
||||||
|
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
|
||||||
|
|
||||||
|
[mod.'gopkg.in/yaml.v3']
|
||||||
|
version = 'v3.0.1'
|
||||||
|
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
|
||||||
|
|
||||||
|
[mod.'modernc.org/libc']
|
||||||
|
version = 'v1.72.3'
|
||||||
|
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
|
||||||
|
|
||||||
|
[mod.'modernc.org/mathutil']
|
||||||
|
version = 'v1.7.1'
|
||||||
|
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
|
||||||
|
|
||||||
|
[mod.'modernc.org/memory']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
|
||||||
|
|
||||||
|
[mod.'modernc.org/sqlite']
|
||||||
|
version = 'v1.50.1'
|
||||||
|
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
buildGoApplication,
|
||||||
|
}: let
|
||||||
|
browser = import ./browser.nix {inherit pkgs;};
|
||||||
|
pname = "spilltea";
|
||||||
|
version = "0.0.7";
|
||||||
|
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";
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
"${pname}" = pkg;
|
||||||
|
default = pkg;
|
||||||
|
browser = browser;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
gitHooksLib,
|
||||||
|
gomod2nixPkgs,
|
||||||
|
}: let
|
||||||
|
hooks = gitHooksLib.run {
|
||||||
|
src = ../.;
|
||||||
|
hooks = {
|
||||||
|
gofmt.enable = true;
|
||||||
|
govet.enable = true;
|
||||||
|
stylua.enable = true;
|
||||||
|
|
||||||
|
gomod2nix = {
|
||||||
|
enable = true;
|
||||||
|
name = "gomod2nix";
|
||||||
|
entry = "gomod2nix --outdir ./nix";
|
||||||
|
language = "system";
|
||||||
|
files = "go\\.(mod|sum)$";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec-basics = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec-basics";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py docs/basics.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
doctoc = {
|
||||||
|
enable = true;
|
||||||
|
name = "doctoc";
|
||||||
|
entry = "doctoc --notitle README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = with pkgs;
|
||||||
|
[
|
||||||
|
go
|
||||||
|
gosec
|
||||||
|
govulncheck
|
||||||
|
python3
|
||||||
|
doctoc
|
||||||
|
stylua
|
||||||
|
trufflehog
|
||||||
|
gomod2nixPkgs.gomod2nix
|
||||||
|
]
|
||||||
|
++ hooks.enabledPackages;
|
||||||
|
|
||||||
|
shellHook = hooks.shellHook;
|
||||||
|
}
|
||||||
+21
-15
@@ -1,28 +1,34 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Inject Header",
|
name = "Inject Header",
|
||||||
description = [[
|
description = [[
|
||||||
Inject custom headers into every intercepted request.
|
Inject custom headers into every intercepted request.
|
||||||
|
|
||||||
**Config**:
|
**Config**:
|
||||||
- one 'Header-Name: value' per line.
|
```yaml
|
||||||
|
headers:
|
||||||
|
- "X-My-Header: myvalue"
|
||||||
|
```
|
||||||
]],
|
]],
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local headers = {}
|
local headers = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
headers = {}
|
headers = {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
if cfg and cfg.headers then
|
||||||
if name and value then
|
for _, line in ipairs(cfg.headers) do
|
||||||
table.insert(headers, { name = name, value = value })
|
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||||
end
|
if name and value then
|
||||||
end
|
table.insert(headers, { name = name, value = value })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
for _, h in ipairs(headers) do
|
for _, h in ipairs(headers) do
|
||||||
req:set_header(h.name, h.value)
|
req:set_header(h.name, h.value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+54
-50
@@ -1,70 +1,74 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "IP Filter (Whitelist/Blacklist)",
|
name = "IP Filter (Whitelist/Blacklist)",
|
||||||
description = [[
|
description = [[
|
||||||
Checks that the proxy's outbound IP is in an allowed list on startup.
|
Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||||
|
|
||||||
**Config**:
|
**Config**:
|
||||||
- one IP per line
|
```yaml
|
||||||
- prefix with `!` for a blacklist entry (blocked)
|
ips:
|
||||||
- prefix with `#` to comment it out (ignored)
|
- "1.2.3.4" # whitelist entry
|
||||||
- if no IPs are configured, the check is skipped
|
- "!5.6.7.8" # blacklist entry (blocked)
|
||||||
|
```
|
||||||
|
|
||||||
|
- If no IPs are configured, the check is skipped.
|
||||||
]],
|
]],
|
||||||
on_start = { sync = false },
|
on_start = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
whitelist = {}
|
whitelist, blacklist = {}, {}
|
||||||
blacklist = {}
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.ips then
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
for _, entry in ipairs(cfg.ips) do
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
local trimmed = entry:match("^%s*(.-)%s*$")
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
if trimmed ~= "" then
|
||||||
if trimmed:sub(1, 1) == "!" then
|
if trimmed:sub(1, 1) == "!" then
|
||||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||||
if ip ~= "" then
|
if ip ~= "" then
|
||||||
table.insert(blacklist, ip)
|
table.insert(blacklist, ip)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
table.insert(whitelist, trimmed)
|
table.insert(whitelist, trimmed)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_start()
|
function on_start()
|
||||||
if #whitelist == 0 and #blacklist == 0 then
|
if #whitelist == 0 and #blacklist == 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||||
result = result and result:match("^%s*(.-)%s*$") or nil
|
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||||
|
|
||||||
if err or not result or result == "" then
|
if err or not result or result == "" then
|
||||||
log("could not determine outbound IP, skipping check")
|
log("could not determine outbound IP, skipping check")
|
||||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, ip in ipairs(blacklist) do
|
for _, ip in ipairs(blacklist) do
|
||||||
if result == ip then
|
if result == ip then
|
||||||
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if #whitelist == 0 then
|
if #whitelist == 0 then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
for _, ip in ipairs(whitelist) do
|
for _, ip in ipairs(whitelist) do
|
||||||
if result == ip then
|
if result == ip then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
|
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
Plugin = {
|
||||||
|
name = "Match & Replace",
|
||||||
|
description = [[
|
||||||
|
Automatically find and replace content in requests and responses.
|
||||||
|
|
||||||
|
**Config**:
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- on: "request" # "request", "response", or "both" (default: "both")
|
||||||
|
url: "example%.com" # optional: Lua pattern to filter by URL
|
||||||
|
target: "body" # "body", "path", "url", or "header:Name"
|
||||||
|
find: "old_string" # Lua pattern to search
|
||||||
|
replace: "new_string" # replacement string (supports %1, %2 for captures)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (inject a debug flag in JSON bodies):
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- on: "both"
|
||||||
|
url: "api%.example%.com"
|
||||||
|
target: "body"
|
||||||
|
find: '"debug":false'
|
||||||
|
replace: '"debug":true'
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (replace an Authorization header):
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- on: "request"
|
||||||
|
target: "header:Authorization"
|
||||||
|
find: "Bearer .*"
|
||||||
|
replace: "Bearer MY_NEW_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (rewrite a path prefix):
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- on: "request"
|
||||||
|
url: "staging%.example%.com"
|
||||||
|
target: "path"
|
||||||
|
find: "^/v1/"
|
||||||
|
replace: "/v2/"
|
||||||
|
```
|
||||||
|
]],
|
||||||
|
priority = 50,
|
||||||
|
on_request = { sync = true },
|
||||||
|
on_response = { sync = true },
|
||||||
|
}
|
||||||
|
|
||||||
|
local rules = {}
|
||||||
|
|
||||||
|
function on_config()
|
||||||
|
rules = {}
|
||||||
|
local cfg = get_config()
|
||||||
|
if not cfg or not cfg.rules then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, r in ipairs(cfg.rules) do
|
||||||
|
if r.find and r.find ~= "" then
|
||||||
|
table.insert(rules, {
|
||||||
|
on = r.on or "both",
|
||||||
|
url = r.url,
|
||||||
|
target = r.target or "body",
|
||||||
|
find = r.find,
|
||||||
|
replace = r.replace or "",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_apply(rule, req, context)
|
||||||
|
if rule.on ~= "both" and rule.on ~= context then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if rule.url and not req.url:match(rule.url) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply(rule, req, res, context)
|
||||||
|
local target = rule.target
|
||||||
|
if target == "body" then
|
||||||
|
local obj = (context == "response") and res or req
|
||||||
|
local body = obj:get_body()
|
||||||
|
local new_body = body:gsub(rule.find, rule.replace)
|
||||||
|
if new_body ~= body then
|
||||||
|
obj:set_body(new_body)
|
||||||
|
end
|
||||||
|
elseif target == "path" then
|
||||||
|
local new_path = req.path:gsub(rule.find, rule.replace)
|
||||||
|
if new_path ~= req.path then
|
||||||
|
req:set_path(new_path)
|
||||||
|
end
|
||||||
|
elseif target == "url" then
|
||||||
|
local new_url = req.url:gsub(rule.find, rule.replace)
|
||||||
|
if new_url ~= req.url then
|
||||||
|
req:set_url(new_url)
|
||||||
|
end
|
||||||
|
elseif target:sub(1, 7) == "header:" then
|
||||||
|
local name = target:sub(8)
|
||||||
|
local obj = (context == "response") and res or req
|
||||||
|
local val = obj.headers[name] or ""
|
||||||
|
local new_val = val:gsub(rule.find, rule.replace)
|
||||||
|
if new_val ~= val then
|
||||||
|
obj:set_header(name, new_val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_request(req)
|
||||||
|
for _, rule in ipairs(rules) do
|
||||||
|
if should_apply(rule, req, "request") then
|
||||||
|
apply(rule, req, nil, "request")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_response(req, res)
|
||||||
|
for _, rule in ipairs(rules) do
|
||||||
|
if should_apply(rule, req, "response") then
|
||||||
|
apply(rule, req, res, "response")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+91
-74
@@ -1,104 +1,121 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Scopes",
|
name = "Scopes",
|
||||||
description = [[
|
description = [[
|
||||||
Auto-forward requests and exclude them from history based on patterns.
|
Auto-forward requests and exclude them from history based on patterns.
|
||||||
|
|
||||||
**Config**:
|
**Config**:
|
||||||
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
```yaml
|
||||||
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
patterns:
|
||||||
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
- "pattern" # whitelist: only intercept matching requests/responses and history
|
||||||
- `r:!pattern` - blacklist for requests/responses only
|
- "!pattern" # blacklist: skip matching requests/responses and history
|
||||||
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
- "r:pattern" # whitelist for requests/responses only
|
||||||
- `h:!pattern` - blacklist for history entries only
|
- "r:!pattern" # blacklist for requests/responses only
|
||||||
- lines starting with `#` are comments
|
- "h:pattern" # whitelist for history only
|
||||||
|
- "h:!pattern" # blacklist for history only
|
||||||
|
```
|
||||||
|
|
||||||
Example (ignore static assets):
|
Example (ignore static assets):
|
||||||
```
|
```yaml
|
||||||
!%.css$
|
patterns:
|
||||||
!%.js$
|
- "!%.css$"
|
||||||
!%.png$
|
- "!%.js$"
|
||||||
|
- "!%.png$"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (focus on mytarget.com, skip everything else):
|
Example (focus on mytarget.com):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
|
- "mytarget%.com/"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (intercept mytarget.com except its static assets):
|
Example (disable history):
|
||||||
```
|
```yaml
|
||||||
mytarget%.com/
|
patterns:
|
||||||
!%.css$
|
- "h:^$"
|
||||||
!%.js$
|
|
||||||
!%.png$
|
|
||||||
```
|
|
||||||
|
|
||||||
Example (disable history: whitelist never matches any real URL):
|
|
||||||
```
|
|
||||||
h:^$
|
|
||||||
```
|
```
|
||||||
]],
|
]],
|
||||||
priority = 100,
|
priority = 100,
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
on_response = { sync = true },
|
on_response = { sync = true },
|
||||||
on_history_entry = { sync = true },
|
on_history_entry = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local blacklist = {}
|
local blacklist = {}
|
||||||
local whitelist = {}
|
local whitelist = {}
|
||||||
local blacklist_req = {}
|
local blacklist_req = {}
|
||||||
local whitelist_req = {}
|
local whitelist_req = {}
|
||||||
local blacklist_hist = {}
|
local blacklist_hist = {}
|
||||||
local whitelist_hist = {}
|
local whitelist_hist = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
blacklist, whitelist = {}, {}
|
blacklist, whitelist = {}, {}
|
||||||
blacklist_req, whitelist_req = {}, {}
|
blacklist_req, whitelist_req = {}, {}
|
||||||
blacklist_hist, whitelist_hist = {}, {}
|
blacklist_hist, whitelist_hist = {}, {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
local trimmed = line:match("^%s*(.-)%s*$")
|
if not cfg or not cfg.patterns then
|
||||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
return
|
||||||
local scope = trimmed:match("^([rh]):")
|
end
|
||||||
local rest = scope and trimmed:sub(3) or trimmed
|
for _, line in ipairs(cfg.patterns) do
|
||||||
local is_bl = rest:sub(1, 1) == "!"
|
local trimmed = line:match("^%s*(.-)%s*$")
|
||||||
local pattern = is_bl and rest:sub(2) or rest
|
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||||
if scope == "r" then
|
local scope = trimmed:match("^([rh]):")
|
||||||
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
local rest = scope and trimmed:sub(3) or trimmed
|
||||||
elseif scope == "h" then
|
local is_bl = rest:sub(1, 1) == "!"
|
||||||
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
local pattern = is_bl and rest:sub(2) or rest
|
||||||
else
|
if scope == "r" then
|
||||||
table.insert(is_bl and blacklist or whitelist, pattern)
|
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||||
end
|
elseif scope == "h" then
|
||||||
end
|
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||||
end
|
else
|
||||||
|
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function check_skip(url, bl_extra, wl_extra)
|
local function check_skip(url, bl_extra, wl_extra)
|
||||||
for _, p in ipairs(blacklist) do
|
for _, p in ipairs(blacklist) do
|
||||||
if url:match(p) then return true end
|
if url:match(p) then
|
||||||
end
|
return true
|
||||||
for _, p in ipairs(bl_extra) do
|
end
|
||||||
if url:match(p) then return true end
|
end
|
||||||
end
|
for _, p in ipairs(bl_extra) do
|
||||||
local wl = {}
|
if url:match(p) then
|
||||||
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
|
return true
|
||||||
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
|
end
|
||||||
if #wl > 0 then
|
end
|
||||||
for _, p in ipairs(wl) do
|
local wl = {}
|
||||||
if url:match(p) then return false end
|
for _, p in ipairs(whitelist) do
|
||||||
end
|
wl[#wl + 1] = p
|
||||||
return true
|
end
|
||||||
end
|
for _, p in ipairs(wl_extra) do
|
||||||
return false
|
wl[#wl + 1] = p
|
||||||
|
end
|
||||||
|
if #wl > 0 then
|
||||||
|
for _, p in ipairs(wl) do
|
||||||
|
if url:match(p) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
if check_skip(req.url, blacklist_req, whitelist_req) then
|
||||||
|
return "forward"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_response(req)
|
function on_response(req)
|
||||||
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
if check_skip(req.url, blacklist_req, whitelist_req) then
|
||||||
|
return "forward"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_history_entry(entry)
|
function on_history_entry(entry)
|
||||||
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
|
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then
|
||||||
|
return "skip"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+168
-120
@@ -1,70 +1,91 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "Secret Scan",
|
name = "Secret Scan",
|
||||||
description = [[
|
description = [[
|
||||||
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
||||||
secrets by matching common secret key names followed by a non-trivial value.
|
secrets by matching common secret key names followed by a non-trivial value.
|
||||||
|
|
||||||
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
||||||
]],
|
]],
|
||||||
on_request = { sync = false },
|
on_request = { sync = false },
|
||||||
on_response = { sync = false },
|
on_response = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local CONTENT_TYPES = {
|
local CONTENT_TYPES = {
|
||||||
"text/html",
|
"text/html",
|
||||||
"text/javascript",
|
"text/javascript",
|
||||||
"application/javascript",
|
"application/javascript",
|
||||||
"application/json",
|
"application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Key name alternation (case-insensitive via grep -i)
|
-- Key name alternation (case-insensitive via grep -i)
|
||||||
-- Suffixes are required (no bare generic keyword alone).
|
-- Suffixes are required (no bare generic keyword alone).
|
||||||
local KEYS = {
|
local KEYS = {
|
||||||
"access(_key|_token)", "accessid_secret", "account(_key|_sid)",
|
"access(_key|_token)",
|
||||||
"admin_pass(word)?", "admin_user",
|
"accessid_secret",
|
||||||
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
|
"account(_key|_sid)",
|
||||||
"ansible_vault_password", "aos_key",
|
"admin_pass(word)?",
|
||||||
"api(_key|_secret|_token)",
|
"admin_user",
|
||||||
"app_(id|key|secret)", "application(_key|_id|_secret)",
|
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
|
||||||
"auth(_token|_secret|orization)", "authkey", "authsecret",
|
"ansible_vault_password",
|
||||||
"bearer_?token",
|
"aos_key",
|
||||||
"bucket(_password|_key)",
|
"api(_key|_secret|_token)",
|
||||||
"cert_?pass(word)?", "certificate_password",
|
"app_(id|key|secret)",
|
||||||
"client(_id|_secret)",
|
"application(_key|_id|_secret)",
|
||||||
"codecov_token", "consumer_(key|secret)",
|
"auth(_token|_secret|orization)",
|
||||||
"connection_?string", "credentials?", "crypt(_key|_secret)",
|
"authkey",
|
||||||
"db_(password|passwd|user(name)?)",
|
"authsecret",
|
||||||
"deploy(_key|_password|_token)",
|
"bearer_?token",
|
||||||
"docker_?pass(word)?", "dockerhub_?password",
|
"bucket(_password|_key)",
|
||||||
"encryption_(key|password)",
|
"cert_?pass(word)?",
|
||||||
"jwt_secret", "json_web_token",
|
"certificate_password",
|
||||||
"keycloak_secret", "kubernetes_token",
|
"client(_id|_secret)",
|
||||||
"ldap_(password|bindpw)", "login(_password|_token)",
|
"codecov_token",
|
||||||
"mail_?password", "mail_smtp_pass",
|
"consumer_(key|secret)",
|
||||||
"mysql_password", "mongo_password",
|
"connection_?string",
|
||||||
"netlify_token", "npm(_token|_auth_token)",
|
"credentials?",
|
||||||
"oauth(_token|_secret)",
|
"crypt(_key|_secret)",
|
||||||
"openai_(api_key|secret)",
|
"db_(password|passwd|user(name)?)",
|
||||||
"pass(word)?", "passwd",
|
"deploy(_key|_password|_token)",
|
||||||
"private(_key|_token)",
|
"docker_?pass(word)?",
|
||||||
"rds_password",
|
"dockerhub_?password",
|
||||||
"s3(_key|_secret|_access_key_id)",
|
"encryption_(key|password)",
|
||||||
"secret(_key|_token|_id)", "security_token",
|
"jwt_secret",
|
||||||
"sendgrid_api_key",
|
"json_web_token",
|
||||||
"ses_(smtp|access|secret)",
|
"keycloak_secret",
|
||||||
"service(_account|_key|_token)",
|
"kubernetes_token",
|
||||||
"smtp_pass(word)?", "smtp_secret",
|
"ldap_(password|bindpw)",
|
||||||
"sonar_token",
|
"login(_password|_token)",
|
||||||
"ssh(_key|_private_key|_rsa)",
|
"mail_?password",
|
||||||
"supabase(_anon|_service)?_key",
|
"mail_smtp_pass",
|
||||||
"symfony_secret",
|
"mysql_password",
|
||||||
"telegram_bot_token",
|
"mongo_password",
|
||||||
"token",
|
"netlify_token",
|
||||||
"travis_token",
|
"npm(_token|_auth_token)",
|
||||||
"vault(_token|_secret)",
|
"oauth(_token|_secret)",
|
||||||
"webhook(_secret|_token)",
|
"openai_(api_key|secret)",
|
||||||
"zapier_webhook_token",
|
"pass(word)?",
|
||||||
|
"passwd",
|
||||||
|
"private(_key|_token)",
|
||||||
|
"rds_password",
|
||||||
|
"s3(_key|_secret|_access_key_id)",
|
||||||
|
"secret(_key|_token|_id)",
|
||||||
|
"security_token",
|
||||||
|
"sendgrid_api_key",
|
||||||
|
"ses_(smtp|access|secret)",
|
||||||
|
"service(_account|_key|_token)",
|
||||||
|
"smtp_pass(word)?",
|
||||||
|
"smtp_secret",
|
||||||
|
"sonar_token",
|
||||||
|
"ssh(_key|_private_key|_rsa)",
|
||||||
|
"supabase(_anon|_service)?_key",
|
||||||
|
"symfony_secret",
|
||||||
|
"telegram_bot_token",
|
||||||
|
"token",
|
||||||
|
"travis_token",
|
||||||
|
"vault(_token|_secret)",
|
||||||
|
"webhook(_secret|_token)",
|
||||||
|
"zapier_webhook_token",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Built once at load time.
|
-- Built once at load time.
|
||||||
@@ -74,93 +95,120 @@ local KEYS = {
|
|||||||
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
||||||
-- [[:space:]]*"? optional whitespace + opening quote
|
-- [[:space:]]*"? optional whitespace + opening quote
|
||||||
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
||||||
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
||||||
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
|
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
|
||||||
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
||||||
|
|
||||||
local function is_relevant(ct)
|
local function is_relevant(ct)
|
||||||
if not ct or ct == "" then return false end
|
if not ct or ct == "" then
|
||||||
ct = ct:lower()
|
return false
|
||||||
for _, t in ipairs(CONTENT_TYPES) do
|
end
|
||||||
if ct:find(t, 1, true) then return true end
|
ct = ct:lower()
|
||||||
end
|
for _, t in ipairs(CONTENT_TYPES) do
|
||||||
return false
|
if ct:find(t, 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function build_context(lines, linenum)
|
local function build_context(lines, linenum)
|
||||||
local lo = math.max(1, linenum - 6)
|
local lo = math.max(1, linenum - 6)
|
||||||
local hi = math.min(#lines, linenum + 6)
|
local hi = math.min(#lines, linenum + 6)
|
||||||
|
|
||||||
local before, after = {}, {}
|
local before, after = {}, {}
|
||||||
for i = lo, linenum - 1 do
|
for i = lo, linenum - 1 do
|
||||||
local l = lines[i] or ""
|
local l = lines[i] or ""
|
||||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
if #l > 120 then
|
||||||
table.insert(before, l)
|
l = l:sub(1, 120) .. "..."
|
||||||
end
|
end
|
||||||
for i = linenum + 1, hi do
|
table.insert(before, l)
|
||||||
local l = lines[i] or ""
|
end
|
||||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
for i = linenum + 1, hi do
|
||||||
table.insert(after, l)
|
local l = lines[i] or ""
|
||||||
end
|
if #l > 120 then
|
||||||
|
l = l:sub(1, 120) .. "..."
|
||||||
|
end
|
||||||
|
table.insert(after, l)
|
||||||
|
end
|
||||||
|
|
||||||
local matched_line = lines[linenum] or ""
|
local matched_line = lines[linenum] or ""
|
||||||
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
|
if #matched_line > 200 then
|
||||||
|
matched_line = matched_line:sub(1, 200) .. "..."
|
||||||
|
end
|
||||||
|
|
||||||
local parts = {}
|
local parts = {}
|
||||||
if #before > 0 then
|
if #before > 0 then
|
||||||
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||||
end
|
end
|
||||||
table.insert(parts, "> **`" .. matched_line .. "`**")
|
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||||
if #after > 0 then
|
if #after > 0 then
|
||||||
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||||
end
|
end
|
||||||
return table.concat(parts, "\n\n")
|
return table.concat(parts, "\n\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scan(label, ct, body, host, path)
|
local function scan(label, ct, body, host, path)
|
||||||
if not is_relevant(ct) then return end
|
if not is_relevant(ct) then
|
||||||
if not body or body == "" then return end
|
return
|
||||||
|
end
|
||||||
|
if not body or body == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local out, err = shell_pipe(GREP_CMD, body)
|
local out, err = shell_pipe(GREP_CMD, body)
|
||||||
if err and err ~= "" then
|
if err and err ~= "" then
|
||||||
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if not out or out == "" then return end
|
if not out or out == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local lines = {}
|
local lines = {}
|
||||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
table.insert(lines, line)
|
table.insert(lines, line)
|
||||||
end
|
end
|
||||||
|
|
||||||
for entry in out:gmatch("[^\n]+") do
|
for entry in out:gmatch("[^\n]+") do
|
||||||
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||||
if linenum_str then
|
if linenum_str then
|
||||||
local linenum = tonumber(linenum_str)
|
local linenum = tonumber(linenum_str)
|
||||||
matched = matched:match("^%s*(.-)%s*$")
|
matched = matched:match("^%s*(.-)%s*$")
|
||||||
if matched ~= "" then
|
if matched ~= "" then
|
||||||
local display = matched
|
local display = matched
|
||||||
if #display > 200 then display = display:sub(1, 200) .. "..." end
|
if #display > 200 then
|
||||||
local ctx = build_context(lines, linenum)
|
display = display:sub(1, 200) .. "..."
|
||||||
create_finding({
|
end
|
||||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
local ctx = build_context(lines, linenum)
|
||||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
|
create_finding({
|
||||||
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||||
severity = "high",
|
description = "**Host:** `"
|
||||||
})
|
.. host
|
||||||
end
|
.. "`\n"
|
||||||
end
|
.. "\n**Path:** `"
|
||||||
end
|
.. path
|
||||||
|
.. "`\n"
|
||||||
|
.. "\n**Match:** `"
|
||||||
|
.. display
|
||||||
|
.. "`\n\n"
|
||||||
|
.. ctx,
|
||||||
|
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||||
|
severity = "high",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_response(req, res)
|
function on_response(req, res)
|
||||||
local ct = ""
|
local ct = ""
|
||||||
if res.headers then
|
if res.headers then
|
||||||
ct = res.headers["Content-Type"] or ""
|
ct = res.headers["Content-Type"] or ""
|
||||||
end
|
end
|
||||||
scan("response", ct, res:get_body(), req.host, req.path)
|
scan("response", ct, res:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|||||||
+51
-45
@@ -1,63 +1,69 @@
|
|||||||
Plugin = {
|
Plugin = {
|
||||||
name = "TruffleHog",
|
name = "TruffleHog",
|
||||||
description = [[
|
description = [[
|
||||||
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
||||||
|
|
||||||
Requires `trufflehog` v3+ to be installed and available in PATH.
|
Requires `trufflehog` v3+ to be installed and available in PATH.
|
||||||
|
|
||||||
Each finding is stored on the **Findings** page with the matched detector output.
|
|
||||||
Findings are deduplicated per host+path+body content so repeated requests do not create duplicates.
|
|
||||||
]],
|
]],
|
||||||
on_start = { sync = false },
|
on_start = { sync = false },
|
||||||
on_request = { sync = false },
|
on_request = { sync = false },
|
||||||
on_response = { sync = false },
|
on_response = { sync = false },
|
||||||
disable_by_default = true,
|
disable_by_default = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function on_start()
|
function on_start()
|
||||||
local handle = io.popen("command -v trufflehog 2>/dev/null")
|
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||||
local result = handle and handle:read("*a") or ""
|
if not result or result:match("^%s*$") then
|
||||||
if handle then handle:close() end
|
log("trufflehog is not installed or not in PATH")
|
||||||
if not result or result:match("^%s*$") then
|
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||||
log("trufflehog is not installed or not in PATH")
|
return false
|
||||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
end
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function scan(label, content, host, path)
|
local function scan(label, content, host, path)
|
||||||
if not content or content == "" then return end
|
if not content or content == "" then
|
||||||
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content)
|
return
|
||||||
if err and err ~= "" then
|
end
|
||||||
log("trufflehog error on " .. label .. ": " .. err)
|
local out, err = shell_pipe(
|
||||||
return
|
'f=$(mktemp) && cat > "$f" && trufflehog filesystem --no-color "$f"; rc=$?; rm -f "$f"; exit $rc',
|
||||||
end
|
content
|
||||||
if not out or out == "" then return end
|
)
|
||||||
local blocks = {}
|
if err and err ~= "" then
|
||||||
local current = nil
|
log("trufflehog error on " .. label .. ": " .. err)
|
||||||
for line in out:gmatch("[^\n]+") do
|
return
|
||||||
if line:match("^Found ") then
|
end
|
||||||
if current then table.insert(blocks, current) end
|
if not out or out == "" then
|
||||||
current = line
|
return
|
||||||
elseif current then
|
end
|
||||||
current = current .. "\n" .. line
|
local blocks = {}
|
||||||
end
|
local current = nil
|
||||||
end
|
for line in out:gmatch("[^\n]+") do
|
||||||
if current then table.insert(blocks, current) end
|
if line:match("^Found ") then
|
||||||
for _, block in ipairs(blocks) do
|
if current then
|
||||||
create_finding({
|
table.insert(blocks, current)
|
||||||
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
end
|
||||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
current = line
|
||||||
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
elseif current then
|
||||||
severity = "high",
|
current = current .. "\n" .. line
|
||||||
})
|
end
|
||||||
end
|
end
|
||||||
|
if current then
|
||||||
|
table.insert(blocks, current)
|
||||||
|
end
|
||||||
|
for _, block in ipairs(blocks) do
|
||||||
|
create_finding({
|
||||||
|
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
||||||
|
description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
||||||
|
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
||||||
|
severity = "high",
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
scan("request", req:get_body(), req.host, req.path)
|
scan("request", req:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_response(req, res)
|
function on_response(req, res)
|
||||||
scan("response", res:get_body(), req.host, req.path)
|
scan("response", res:get_body(), req.host, req.path)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed docs
|
||||||
|
var DocsFS embed.FS
|
||||||
|
|
||||||
//go:embed plugins/*.lua
|
//go:embed plugins/*.lua
|
||||||
var PluginsFS embed.FS
|
var PluginsFS embed.FS
|
||||||
|
|
||||||
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
|
||||||
// files that already exist. Returns the number of files written.
|
// files that already exist. Returns the number of files written.
|
||||||
func InstallDefaultPlugins(dir string) (int, error) {
|
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)
|
return 0, fmt.Errorf("create plugins dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ func InstallDefaultPlugins(dir string) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
|
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)
|
return written, fmt.Errorf("write %s: %w", dst, err)
|
||||||
}
|
}
|
||||||
written++
|
written++
|
||||||
Reference in New Issue
Block a user