summaryrefslogtreecommitdiff
path: root/internal/ui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui')
-rw-r--r--internal/ui/app.go239
-rw-r--r--internal/ui/browser.go283
-rw-r--r--internal/ui/decoder.go254
-rw-r--r--internal/ui/format.go120
-rw-r--r--internal/ui/har.go88
-rw-r--r--internal/ui/helpers.go91
-rw-r--r--internal/ui/history.go1303
-rw-r--r--internal/ui/history_test.go124
-rw-r--r--internal/ui/inspector.go125
-rw-r--r--internal/ui/intercept.go216
-rw-r--r--internal/ui/intruder.go742
-rw-r--r--internal/ui/keybinds.go92
-rw-r--r--internal/ui/launch.go219
-rw-r--r--internal/ui/loot.go394
-rw-r--r--internal/ui/plugins.go179
-rw-r--r--internal/ui/repeater.go224
-rw-r--r--internal/ui/repeater_http.go310
-rw-r--r--internal/ui/repeater_test.go378
-rw-r--r--internal/ui/repeater_ws.go418
-rw-r--r--internal/ui/scope.go126
-rw-r--r--internal/ui/settings.go401
-rw-r--r--internal/ui/theme.go373
-rw-r--r--internal/ui/websocket.go379
-rw-r--r--internal/ui/widgets/datatable.go876
-rw-r--r--internal/ui/widgets/datatable_test.go199
-rw-r--r--internal/ui/widgets/textview.go836
-rw-r--r--internal/ui/widgets/textview_test.go189
-rw-r--r--internal/ui/widgets/textviewentry.go1589
-rw-r--r--internal/ui/widgets/textviewentry_test.go381
-rw-r--r--internal/ui/widgets/tokeniser.go362
-rw-r--r--internal/ui/widgets/tokeniser_test.go213
31 files changed, 11723 insertions, 0 deletions
diff --git a/internal/ui/app.go b/internal/ui/app.go
new file mode 100644
index 0000000..27d6f9a
--- /dev/null
+++ b/internal/ui/app.go
@@ -0,0 +1,239 @@
+package ui
+
+import (
+ "fmt"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/data/binding"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/assets"
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/store"
+)
+
+const (
+ prefKeyDarkTheme = "dark_theme"
+ prefKeyProxyHost = "proxy_host"
+ prefKeyProxyPort = "proxy_port"
+ prefKeyProxyEnabled = "proxy_enabled"
+ prefKeyUserTheme = "user_theme"
+
+ defaultDarkTheme = true
+ defaultProxyHost = "127.0.0.1"
+ defaultProxyPort = 9090
+ defaultProxyEnabled = true
+)
+
+// Package-level bindings shared between app.go and settings.go.
+// Both files are in the same package so this is safe — there is only
+// ever one app instance in a desktop process.
+var proxyStatusBinding = binding.NewString()
+var proxyRunningBinding = binding.NewBool()
+
+// loadActiveTheme reads the user theme from prefs and loads it.
+// Returns nil if no user theme is set or loading fails (falls back to default).
+func loadActiveTheme(fyneApp fyne.App) *LoadedTheme {
+ name := fyneApp.Preferences().String(prefKeyUserTheme)
+ if name == "" {
+ return nil
+ }
+ dir := ThemesDir(fyneApp.Storage().RootURI().Path())
+ lt, err := loadThemeByName(name, dir)
+ if err != nil {
+ return nil
+ }
+ return lt
+}
+
+// applyTheme reads current dark/light and user theme prefs and applies the theme.
+// Call this from anywhere — toggle, settings, startup. No shared mutable state.
+func applyTheme(fyneApp fyne.App) {
+ isDark := fyneApp.Preferences().BoolWithFallback(prefKeyDarkTheme, defaultDarkTheme)
+ fyneApp.Settings().SetTheme(NewVagueThemeWithLoaded(isDark, loadActiveTheme(fyneApp)))
+}
+
+// setProxyStatus updates both package-level bindings and persists the running
+// state to prefs. Must be called on the Fyne main goroutine.
+func setProxyStatus(prefs fyne.Preferences, running bool) {
+ proxyHost := prefs.StringWithFallback(prefKeyProxyHost, defaultProxyHost)
+ proxyPort := prefs.IntWithFallback(prefKeyProxyPort, defaultProxyPort)
+ prefs.SetBool(prefKeyProxyEnabled, running)
+ _ = proxyRunningBinding.Set(running)
+ if running {
+ _ = proxyStatusBinding.Set(fmt.Sprintf("Proxy: running on %s:%d", proxyHost, proxyPort))
+ } else {
+ _ = proxyStatusBinding.Set("Proxy: stopped")
+ }
+}
+
+func ShowMainWindow(fyneApp fyne.App, projectStore *store.Store, bus *events.Bus, launchWin fyne.Window, onSwitchProject func(string)) {
+ prefs := fyneApp.Preferences()
+
+ mainWin := fyneApp.NewWindow("Shiv")
+ mainWin.Resize(fyne.NewSize(1280, 800))
+ mainWin.SetIcon(fyne.NewStaticResource("logo.png", assets.Logo))
+
+ mainWin.SetMaster()
+ applyTheme(fyneApp)
+
+ browserBtn := widget.NewButtonWithIcon("", AppIcon("web"), func() {
+ launchDefaultBrowser(fyneApp, mainWin)
+ })
+
+ proxyToggleBtn := widget.NewButtonWithIcon("", AppIcon("off-button"), nil)
+
+ proxyRunningBinding.AddListener(binding.NewDataListener(func() {
+ running, _ := proxyRunningBinding.Get()
+ if running {
+ proxyToggleBtn.SetIcon(AppIcon("on-button"))
+ } else {
+ proxyToggleBtn.SetIcon(AppIcon("off-button"))
+ }
+ proxyToggleBtn.Refresh()
+ }))
+
+ proxyToggleBtn.OnTapped = func() {
+ isRunning, _ := proxyRunningBinding.Get()
+ isRunning = !isRunning
+
+ if isRunning {
+ proxyHost := prefs.StringWithFallback(prefKeyProxyHost, defaultProxyHost)
+ proxyPort := prefs.IntWithFallback(prefKeyProxyPort, defaultProxyPort)
+ addr := fmt.Sprintf("%s:%d", proxyHost, proxyPort)
+ bus.EmitProxyRestart(events.ProxyRestartEvent{Addr: addr})
+ } else {
+ bus.EmitProxyStop(events.ProxyStopEvent{})
+ }
+
+ setProxyStatus(prefs, isRunning)
+ }
+
+ // Initialise status from persisted prefs.
+ setProxyStatus(prefs, prefs.BoolWithFallback(prefKeyProxyEnabled, defaultProxyEnabled))
+
+ proxyLabel := widget.NewLabelWithData(proxyStatusBinding)
+ proxyLabel.Importance = widget.LowImportance
+
+ logo := canvas.NewImageFromResource(fyne.NewStaticResource("logo.png", assets.Logo))
+ logo.FillMode = canvas.ImageFillContain
+ logo.SetMinSize(fyne.NewSize(24, 24))
+
+ appName := widget.NewLabel("Shiv")
+ appName.TextStyle = fyne.TextStyle{Bold: true}
+
+ repeater := newRepeaterTab(projectStore, bus, mainWin)
+ loot := newLootTab(projectStore, mainWin, repeater)
+ repeater.loot = loot
+ intruder := newIntruderTab(mainWin, projectStore, repeater, loot)
+ history := newHistoryTab(projectStore, mainWin, repeater, loot, intruder)
+ intercept := newInterceptTab(projectStore)
+ plugins := newPluginsTab(projectStore, bus, mainWin)
+ decoder := newDecoderTab()
+ tabs := container.NewAppTabs(
+ container.NewTabItemWithIcon("History", AppIcon("history"), history.build()),
+ container.NewTabItemWithIcon("Intercept", AppIcon("intercept"), intercept.build()),
+ container.NewTabItemWithIcon("Repeater", AppIcon("repeater"), repeater.build()),
+ container.NewTabItemWithIcon("Intruder", AppIcon("intruder"), intruder.build()),
+ container.NewTabItemWithIcon("Decoder", AppIcon("toolbox"), decoder.build()),
+ container.NewTabItemWithIcon("Loot", AppIcon("loot"), loot.build()),
+ container.NewTabItemWithIcon("Plugins", AppIcon("plugin"), plugins.build()),
+ )
+ tabs.SetTabLocation(container.TabLocationTop)
+
+ keybinds := newKeybinds(mainWin, tabs, history, repeater, prefs)
+
+ settingsMenuItem := fyne.NewMenuItem("Settings", func() {
+ showSettingsDialog(fyneApp, mainWin, bus, keybinds)
+ })
+ settingsMenuItem.Icon = AppIcon("toolbox")
+
+ toggleThemeMenuItem := fyne.NewMenuItem("Toggle Theme", func() {
+ isDark := !prefs.BoolWithFallback(prefKeyDarkTheme, defaultDarkTheme)
+ prefs.SetBool(prefKeyDarkTheme, isDark)
+ applyTheme(fyneApp)
+ })
+ toggleThemeMenuItem.Icon = theme.ColorChromaticIcon()
+ launchBrowserMenuItem := fyne.NewMenuItem("Launch Browser", func() {
+ launchDefaultBrowser(fyneApp, mainWin)
+ })
+
+ newProjectItem := fyne.NewMenuItem("New Project", func() {
+ d := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
+ if err != nil || wc == nil {
+ return
+ }
+ wc.Close()
+ path := wc.URI().Path()
+ saveRecent(path)
+ mainWin.Hide()
+ onSwitchProject(path)
+ }, mainWin)
+ d.SetFileName("untitled.shiv")
+ d.SetFilter(storage.NewExtensionFileFilter([]string{".shiv"}))
+ d.Show()
+ })
+ newProjectItem.Icon = theme.DocumentCreateIcon()
+
+ openProjectItem := fyne.NewMenuItem("Open Project", func() {
+ d := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) {
+ if err != nil || rc == nil {
+ return
+ }
+ rc.Close()
+ path := rc.URI().Path()
+ saveRecent(path)
+ mainWin.Hide()
+ onSwitchProject(path)
+ }, mainWin)
+ d.SetFilter(storage.NewExtensionFileFilter([]string{".shiv"}))
+ d.Show()
+ })
+ openProjectItem.Icon = theme.FolderOpenIcon()
+
+ quitItem := fyne.NewMenuItem("Quit", func() {
+ fyneApp.Quit()
+ })
+
+ menuItems := []*fyne.MenuItem{
+ newProjectItem,
+ openProjectItem,
+ fyne.NewMenuItemSeparator(),
+ launchBrowserMenuItem,
+ toggleThemeMenuItem,
+ settingsMenuItem,
+ fyne.NewMenuItemSeparator(),
+ quitItem,
+ }
+ if lt := loadActiveTheme(fyneApp); lt != nil && !lt.HasBoth {
+ toggleThemeMenuItem.Disabled = true
+ }
+
+ menu := fyne.NewMenu("", menuItems...)
+ var menuBtn *widget.Button
+
+ menuBtn = widget.NewButtonWithIcon("", theme.MenuIcon(), func() {
+ widget.ShowPopUpMenuAtRelativePosition(menu, mainWin.Canvas(), fyne.NewPos(0, menuBtn.Size().Height), menuBtn)
+ })
+
+ optionsBar := container.NewBorder(
+ container.NewBorder(nil, nil, nil,
+ container.NewHBox(proxyLabel, proxyToggleBtn, browserBtn, menuBtn),
+ ),
+ nil, nil, nil,
+ )
+
+ mainWin.SetContent(container.NewStack(
+ tabs,
+ optionsBar,
+ ))
+ mainWin.Show()
+ if launchWin != nil {
+ launchWin.Close()
+ }
+}
diff --git a/internal/ui/browser.go b/internal/ui/browser.go
new file mode 100644
index 0000000..2dddb0f
--- /dev/null
+++ b/internal/ui/browser.go
@@ -0,0 +1,283 @@
+package ui
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/dialog"
+
+ "github.com/shiv/internal/logger"
+)
+
+const prefKeyDefaultBrowser = "default_browser"
+
+// DetectedBrowser holds a browser's display name and executable path.
+type DetectedBrowser struct {
+ Name string
+ Path string
+}
+
+// isFirefox returns true if the executable looks like Firefox.
+func isFirefox(path string) bool {
+ base := strings.ToLower(filepath.Base(path))
+ return strings.Contains(base, "firefox")
+}
+
+// DetectBrowsers scans the system for supported browsers and returns all found.
+func DetectBrowsers() []DetectedBrowser {
+ switch runtime.GOOS {
+ case "linux":
+ return detectBrowsersLinux()
+ case "darwin":
+ return detectBrowsersDarwin()
+ case "windows":
+ return detectBrowsersWindows()
+ }
+ return nil
+}
+
+func detectBrowsersLinux() []DetectedBrowser {
+ candidates := []struct{ name, bin string }{
+ {"Google Chrome", "google-chrome"},
+ {"Google Chrome (stable)", "google-chrome-stable"},
+ {"Chromium", "chromium"},
+ {"Chromium (browser)", "chromium-browser"},
+ {"Brave (Browser)", "brave-browser"},
+ {"Brave", "brave"},
+ {"Microsoft Edge", "microsoft-edge"},
+ {"Vivaldi", "vivaldi"},
+ {"Vivaldi (stable)", "vivaldi-Stable"},
+ {"Opera", "opera"},
+ {"Opera (stable)", "opera-stable"},
+ {"Opera (browser)", "opera-browser"},
+ {"Firefox", "firefox"},
+ {"Firefox ESR", "firefox-esr"},
+ }
+ var found []DetectedBrowser
+ seen := map[string]bool{}
+ for _, c := range candidates {
+ path, err := exec.LookPath(c.bin)
+ if err != nil {
+ continue
+ }
+ if seen[path] {
+ continue
+ }
+ seen[path] = true
+ found = append(found, DetectedBrowser{Name: c.name, Path: path})
+ }
+ return found
+}
+
+func detectBrowsersDarwin() []DetectedBrowser {
+ candidates := []struct{ name, app string }{
+ {"Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"},
+ {"Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"},
+ {"Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"},
+ {"Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"},
+ {"Vivaldi", "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"},
+ {"Opera", "/Applications/Opera.app/Contents/MacOS/Opera"},
+ {"Firefox", "/Applications/Firefox.app/Contents/MacOS/firefox"},
+ }
+ var found []DetectedBrowser
+ for _, c := range candidates {
+ if _, err := os.Stat(c.app); err == nil {
+ found = append(found, DetectedBrowser{Name: c.name, Path: c.app})
+ }
+ }
+ return found
+}
+
+func detectBrowsersWindows() []DetectedBrowser {
+ candidates := []struct{ name, path string }{
+ {"Google Chrome", `C:\Program Files\Google\Chrome\Application\chrome.exe`},
+ {"Google Chrome (x86)", `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`},
+ {"Chromium", `C:\Program Files\Chromium\Application\chrome.exe`},
+ {"Brave", `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`},
+ {"Microsoft Edge", `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`},
+ {"Vivaldi", `C:\Program Files\Vivaldi\Application\vivaldi.exe`},
+ {"Opera", `C:\Program Files\Opera\opera.exe`},
+ {"Firefox", `C:\Program Files\Mozilla Firefox\firefox.exe`},
+ {"Firefox (x86)", `C:\Program Files (x86)\Mozilla Firefox\firefox.exe`},
+ }
+ var found []DetectedBrowser
+ seen := map[string]bool{}
+ for _, c := range candidates {
+ if _, err := os.Stat(c.path); err == nil {
+ if !seen[c.path] {
+ seen[c.path] = true
+ found = append(found, DetectedBrowser{Name: c.name, Path: c.path})
+ }
+ }
+ }
+ return found
+}
+
+// profileDir returns the persistent browser profile directory for the given browser executable.
+func profileDir(fyneApp fyne.App, browserPath string) string {
+ root := fyneApp.Storage().RootURI().Path()
+ if isFirefox(browserPath) {
+ return filepath.Join(root, "browser-profile-firefox")
+ }
+ return filepath.Join(root, "browser-profile-chrome")
+}
+
+// setupFirefoxProfile creates a Firefox profile dir with proxy settings written into user.js.
+func setupFirefoxProfile(profilePath, proxyHost string, proxyPort int) error {
+ if err := os.MkdirAll(profilePath, 0700); err != nil {
+ return fmt.Errorf("create firefox profile dir: %w", err)
+ }
+ userJS := fmt.Sprintf(`// Shiv proxy profile — auto-generated, do not edit manually.
+user_pref("network.proxy.type", 1);
+user_pref("network.proxy.http", "%s");
+user_pref("network.proxy.http_port", %d);
+user_pref("network.proxy.ssl", "%s");
+user_pref("network.proxy.ssl_port", %d);
+user_pref("network.proxy.no_proxies_on", "");
+user_pref("browser.startup.homepage_override.mstone", "ignore");
+user_pref("browser.shell.checkDefaultBrowser", false);
+user_pref("extensions.enabledScopes", 0);
+user_pref("extensions.autoDisableScopes", 15);
+user_pref("network.stricttransportsecurity.preloadlist", false);
+user_pref("security.enterprise_roots.enabled", true);
+`, proxyHost, proxyPort, proxyHost, proxyPort)
+ return os.WriteFile(filepath.Join(profilePath, "user.js"), []byte(userJS), 0600)
+}
+
+// LaunchBrowser launches the given browser pointed at the proxy with an isolated profile.
+func LaunchBrowser(fyneApp fyne.App, browser DetectedBrowser, proxyHost string, proxyPort int) error {
+ profile := profileDir(fyneApp, browser.Path)
+ proxyAddr := fmt.Sprintf("%s:%d", proxyHost, proxyPort)
+
+ var args []string
+
+ if isFirefox(browser.Path) {
+ if err := setupFirefoxProfile(profile, proxyHost, proxyPort); err != nil {
+ return err
+ }
+ args = []string{
+ "-profile", profile,
+ "-no-remote",
+ }
+ } else {
+ // Chromium-based — profile dir is created automatically by the browser.
+ args = []string{
+ "--proxy-server=http://" + proxyAddr,
+ "--user-data-dir=" + profile,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-extensions",
+ "--disable-sync",
+ "--disable-background-networking",
+ "--disable-default-apps",
+ "--disable-component-update",
+ "--disable-breakpad",
+ "--metrics-recording-only",
+ "--disable-features=OptimizationHints,MediaRouter",
+ }
+ }
+
+ cmd := exec.Command(browser.Path, args...)
+ cmd.Stderr = nil
+ cmd.Stdout = nil
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("launch browser: %w", err)
+ }
+
+ logger.Info("browser: launched %s (pid %d) via %s", browser.Name, cmd.Process.Pid, proxyAddr)
+
+ // Detach — we don't want to wait for the browser to exit.
+ go func() {
+ err := cmd.Wait()
+ if err != nil {
+ logger.Error("browser exited early: %v", err)
+ }
+ }()
+ return nil
+}
+
+// ClearBrowserProfile deletes the stored profile for the given browser executable.
+func ClearBrowserProfile(fyneApp fyne.App, browserPath string) error {
+ profile := profileDir(fyneApp, browserPath)
+ if err := os.RemoveAll(profile); err != nil {
+ return fmt.Errorf("clear browser profile: %w", err)
+ }
+ return nil
+}
+
+// launchDefaultBrowser reads the saved default browser from prefs and launches it.
+// If no default is set it shows a picker dialog first.
+func launchDefaultBrowser(fyneApp fyne.App, win fyne.Window) {
+ prefs := fyneApp.Preferences()
+ proxyHost := prefs.StringWithFallback(prefKeyProxyHost, defaultProxyHost)
+ proxyPort := prefs.IntWithFallback(prefKeyProxyPort, defaultProxyPort)
+
+ savedPath := prefs.String(prefKeyDefaultBrowser)
+ if savedPath != "" {
+ // Verify it still exists.
+ if _, err := os.Stat(savedPath); err == nil {
+ browser := DetectedBrowser{Name: filepath.Base(savedPath), Path: savedPath}
+ if err := LaunchBrowser(fyneApp, browser, proxyHost, proxyPort); err != nil {
+ dialog.ShowError(err, win)
+ }
+ return
+ }
+ // Saved browser no longer exists — fall through to picker.
+ prefs.SetString(prefKeyDefaultBrowser, "")
+ }
+
+ browsers := DetectBrowsers()
+ if len(browsers) == 0 {
+ dialog.ShowInformation("No Browser Found",
+ "No supported browser was detected on your system.\nInstall Chrome, Brave, Firefox, or another Chromium-based browser.",
+ win)
+ return
+ }
+
+ // If only one browser found, use it directly and save as default.
+ if len(browsers) == 1 {
+ prefs.SetString(prefKeyDefaultBrowser, browsers[0].Path)
+ if err := LaunchBrowser(fyneApp, browsers[0], proxyHost, proxyPort); err != nil {
+ dialog.ShowError(err, win)
+ }
+ return
+ }
+
+ // Multiple browsers — show picker.
+ showBrowserPickerDialog(fyneApp, win, browsers, proxyHost, proxyPort)
+}
+
+// showBrowserPickerDialog shows a one-time dialog to pick a default browser.
+func showBrowserPickerDialog(fyneApp fyne.App, win fyne.Window, browsers []DetectedBrowser, proxyHost string, proxyPort int) {
+ names := make([]string, len(browsers))
+ for i, b := range browsers {
+ names[i] = b.Name
+ }
+
+ selected := browsers[0]
+ picker := newBrowserPickerContent(names, func(name string) {
+ for _, b := range browsers {
+ if b.Name == name {
+ selected = b
+ break
+ }
+ }
+ })
+
+ d := dialog.NewCustomConfirm("Select Default Browser", "Launch", "Cancel", picker, func(confirmed bool) {
+ if !confirmed {
+ return
+ }
+ fyneApp.Preferences().SetString(prefKeyDefaultBrowser, selected.Path)
+ if err := LaunchBrowser(fyneApp, selected, proxyHost, proxyPort); err != nil {
+ dialog.ShowError(err, win)
+ }
+ }, win)
+ closeOnEscape(win, d.Dismiss)
+ d.Show()
+}
diff --git a/internal/ui/decoder.go b/internal/ui/decoder.go
new file mode 100644
index 0000000..c5c0653
--- /dev/null
+++ b/internal/ui/decoder.go
@@ -0,0 +1,254 @@
+package ui
+
+import (
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html"
+ "net/url"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/widget"
+)
+
+type decoderOperation struct {
+ name string
+ fwdLabel string // label for the forward button
+ bwdLabel string // label for the backward button; empty = no backward
+ forward func(string) string
+ backward func(string) (string, error) // nil = one-way
+}
+
+var decoderOps = []decoderOperation{
+ {
+ name: "URL Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: url.QueryEscape,
+ backward: func(s string) (string, error) { return url.QueryUnescape(s) },
+ },
+ {
+ name: "URL Path Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: url.PathEscape,
+ backward: func(s string) (string, error) { return url.PathUnescape(s) },
+ },
+ {
+ name: "Base64 Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) },
+ backward: func(s string) (string, error) {
+ s = strings.TrimSpace(s)
+ b, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ b, err = base64.URLEncoding.DecodeString(s)
+ }
+ return string(b), err
+ },
+ },
+ {
+ name: "Base64 URL-safe Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: func(s string) string { return base64.URLEncoding.EncodeToString([]byte(s)) },
+ backward: func(s string) (string, error) {
+ b, err := base64.URLEncoding.DecodeString(strings.TrimSpace(s))
+ return string(b), err
+ },
+ },
+ {
+ name: "Hex Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: func(s string) string { return hex.EncodeToString([]byte(s)) },
+ backward: func(s string) (string, error) {
+ b, err := hex.DecodeString(strings.ReplaceAll(strings.TrimSpace(s), " ", ""))
+ return string(b), err
+ },
+ },
+ {
+ name: "HTML Entity Encode / Decode",
+ fwdLabel: "Encode →",
+ bwdLabel: "← Decode",
+ forward: html.EscapeString,
+ backward: func(s string) (string, error) { return html.UnescapeString(s), nil },
+ },
+ {
+ name: "JSON Pretty / Minify",
+ fwdLabel: "Pretty →",
+ bwdLabel: "← Minify",
+ forward: func(s string) string {
+ var v any
+ if err := json.Unmarshal([]byte(s), &v); err != nil {
+ return "Error: " + err.Error()
+ }
+ var buf strings.Builder
+ enc := json.NewEncoder(&buf)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(v); err != nil {
+ return "Error: " + err.Error()
+ }
+ return strings.TrimRight(buf.String(), "\n")
+ },
+ backward: func(s string) (string, error) {
+ var v any
+ if err := json.Unmarshal([]byte(s), &v); err != nil {
+ return "", err
+ }
+ b, err := json.Marshal(v)
+ return string(b), err
+ },
+ },
+ {
+ name: "JWT Decode (no verify)",
+ fwdLabel: "Decode →",
+ forward: func(s string) string {
+ parts := strings.Split(strings.TrimSpace(s), ".")
+ if len(parts) < 2 {
+ return "Error: not a JWT (expected header.payload.signature)"
+ }
+ var result strings.Builder
+ for i, part := range parts[:min(2, len(parts))] {
+ label := []string{"Header", "Payload"}[i]
+ b, err := base64.RawURLEncoding.DecodeString(part)
+ if err != nil {
+ result.WriteString(fmt.Sprintf("=== %s ===\nError: %v\n\n", label, err))
+ continue
+ }
+ result.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", label, string(PrettyJSON(b))))
+ }
+ if len(parts) >= 3 {
+ result.WriteString(fmt.Sprintf("=== Signature ===\n%s", parts[2]))
+ }
+ return result.String()
+ },
+ },
+ {
+ name: "MD5 Hash",
+ fwdLabel: "Hash →",
+ forward: func(s string) string {
+ h := md5.Sum([]byte(s))
+ return hex.EncodeToString(h[:])
+ },
+ },
+ {
+ name: "SHA1 Hash",
+ fwdLabel: "Hash →",
+ forward: func(s string) string {
+ h := sha1.Sum([]byte(s))
+ return hex.EncodeToString(h[:])
+ },
+ },
+ {
+ name: "SHA256 Hash",
+ fwdLabel: "Hash →",
+ forward: func(s string) string {
+ h := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(h[:])
+ },
+ },
+}
+
+type decoderTab struct{}
+
+func newDecoderTab() *decoderTab { return &decoderTab{} }
+
+func (d *decoderTab) build() fyne.CanvasObject {
+ inputEntry := widget.NewMultiLineEntry()
+ inputEntry.SetPlaceHolder("Input...")
+ inputEntry.TextStyle = fyne.TextStyle{Monospace: true}
+ inputEntry.Wrapping = fyne.TextWrapOff
+
+ outputEntry := widget.NewMultiLineEntry()
+ outputEntry.SetPlaceHolder("Output...")
+ outputEntry.TextStyle = fyne.TextStyle{Monospace: true}
+ outputEntry.Wrapping = fyne.TextWrapOff
+
+ opNames := make([]string, len(decoderOps))
+ for i, op := range decoderOps {
+ opNames[i] = op.name
+ }
+
+ selectedIdx := 0
+
+ fwdBtn := widget.NewButton(decoderOps[0].fwdLabel, nil)
+ fwdBtn.Importance = widget.HighImportance
+
+ bwdBtn := widget.NewButton("← Decode", nil)
+
+ fwdBtn.OnTapped = func() {
+ op := decoderOps[selectedIdx]
+ outputEntry.SetText(op.forward(inputEntry.Text))
+ }
+ bwdBtn.OnTapped = func() {
+ op := decoderOps[selectedIdx]
+ if op.backward == nil {
+ return
+ }
+ result, err := op.backward(inputEntry.Text)
+ if err != nil {
+ outputEntry.SetText("Error: " + err.Error())
+ return
+ }
+ outputEntry.SetText(result)
+ }
+
+ opSelect := widget.NewSelect(opNames, func(s string) {
+ for i, op := range decoderOps {
+ if op.name == s {
+ selectedIdx = i
+ fwdBtn.SetText(op.fwdLabel)
+ if op.backward != nil {
+ bwdBtn.SetText(op.bwdLabel)
+ bwdBtn.Show()
+ } else {
+ bwdBtn.Hide()
+ }
+ return
+ }
+ }
+ })
+ opSelect.SetSelected(opNames[0])
+
+ // SHA1/MD5/SHA256/JWT have no backward — hide decode button initially if needed.
+ if decoderOps[0].backward == nil {
+ bwdBtn.Hide()
+ }
+
+ swapBtn := widget.NewButton("⇅ Swap", func() {
+ in := inputEntry.Text
+ inputEntry.SetText(outputEntry.Text)
+ outputEntry.SetText(in)
+ })
+
+ copyBtn := widget.NewButton("Copy Output", func() {
+ fyne.CurrentApp().Clipboard().SetContent(outputEntry.Text)
+ })
+
+ toolbar := container.NewBorder(nil, nil,
+ container.NewHBox(fwdBtn, bwdBtn),
+ container.NewHBox(swapBtn, copyBtn),
+ opSelect,
+ )
+
+ inputPane := container.NewBorder(newBoldLabel("Input"), nil, nil, nil, inputEntry)
+ outputPane := container.NewBorder(newBoldLabel("Output"), nil, nil, nil, outputEntry)
+
+ split := container.NewVSplit(inputPane, outputPane)
+ split.SetOffset(0.5)
+
+ return container.NewBorder(
+ container.New(layout.NewCustomPaddedLayout(8, 8, 0, 0), toolbar),
+ nil, nil, nil,
+ split,
+ )
+}
diff --git a/internal/ui/format.go b/internal/ui/format.go
new file mode 100644
index 0000000..8038d42
--- /dev/null
+++ b/internal/ui/format.go
@@ -0,0 +1,120 @@
+package ui
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/shiv/internal/store"
+)
+
+// PathOf extracts the path+query portion from a full URL string.
+func PathOf(rawURL string) string {
+ for _, prefix := range []string{"https://", "http://"} {
+ if strings.HasPrefix(rawURL, prefix) {
+ rest := rawURL[len(prefix):]
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ return rest[slash:]
+ }
+ return "/"
+ }
+ }
+ return rawURL
+}
+
+// PrettyJSON re-indents JSON, returning the original bytes on any error.
+func PrettyJSON(body []byte) []byte {
+ if len(body) == 0 {
+ return body
+ }
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, body, "", " "); err != nil {
+ return body
+ }
+ return buf.Bytes()
+}
+
+// hostHeader returns the value to use for the Host header in a reconstructed
+// raw request. Default ports (80 for HTTP, 443 for HTTPS) are omitted per
+// RFC 7230 §5.4. Non-standard ports are always included.
+func hostHeader(tx store.Transaction) string {
+ if tx.Port == 0 || (tx.TLS && tx.Port == 443) || (!tx.TLS && tx.Port == 80) {
+ return tx.Host
+ }
+ return tx.Host + ":" + strconv.Itoa(tx.Port)
+}
+
+// FormatStoreRequest serialises a Transaction into a raw HTTP/1.1 request string
+// suitable for display in the UI or sending via Repeater/Intruder.
+//
+// The protocol is always written as HTTP/1.1 regardless of what was captured —
+func FormatStoreRequest(tx store.Transaction) string {
+ proto := tx.Proto
+ if proto == "" {
+ proto = "HTTP/1.1"
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s %s %s\r\n", tx.Method, PathOf(tx.URL), proto)
+ fmt.Fprintf(&b, "Host: %s\r\n", hostHeader(tx))
+
+ keys := sortedKeys(tx.ReqHeaders)
+ for _, k := range keys {
+ // Skip Host — we already wrote a canonical one above.
+ if strings.EqualFold(k, "host") {
+ continue
+ }
+ for _, v := range tx.ReqHeaders[k] {
+ fmt.Fprintf(&b, "%s: %s\r\n", k, v)
+ }
+ }
+ b.WriteString("\r\n")
+ if len(tx.ReqBody) > 0 {
+ b.Write(PrettyJSON(tx.ReqBody))
+ }
+ return b.String()
+}
+
+// FormatStoreResponse serialises a Transaction into a raw HTTP response string
+// suitable for display in the UI.
+//
+// The store already holds decompressed body bytes — we must NOT decompress again.
+func FormatStoreResponse(tx store.Transaction) string {
+ var b strings.Builder
+ // Always render as HTTP/1.1; H2 pseudo-headers are not meaningful in raw text.
+ const proto = "HTTP/1.1"
+ fmt.Fprintf(&b, "%s %d\r\n", proto, tx.StatusCode)
+
+ keys := sortedKeys(tx.RespHeaders)
+ for _, k := range keys {
+ for _, v := range tx.RespHeaders[k] {
+ fmt.Fprintf(&b, "%s: %s\r\n", k, v)
+ }
+ }
+ b.WriteString("\r\n")
+
+ if len(tx.RespBody) > 0 {
+ // tx.RespBody is already decompressed by the proxy before storage.
+ // Do NOT call Decompress here — the body is plain bytes.
+ body := tx.RespBody
+ ct := tx.RespHeaders.Get("Content-Type")
+ if strings.Contains(ct, "application/json") {
+ b.Write(PrettyJSON(body))
+ } else {
+ b.Write(body)
+ }
+ }
+ return b.String()
+}
+
+func sortedKeys(h map[string][]string) []string {
+ keys := make([]string, 0, len(h))
+ for k := range h {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/ui/har.go b/internal/ui/har.go
new file mode 100644
index 0000000..ba3e085
--- /dev/null
+++ b/internal/ui/har.go
@@ -0,0 +1,88 @@
+package ui
+
+import (
+ "fmt"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/dialog"
+
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+)
+
+// exportHAR exports all transactions matching the current filter to a HAR file.
+// It paginates through the DB so all rows are exported, not just the visible page.
+func (h *historyTab) exportHAR() {
+ if len(h.displayed) == 0 {
+ dialog.ShowInformation("Export HAR", "No transactions to export.", h.win)
+ return
+ }
+
+ f := h.currentFilter()
+
+ go func() {
+ // Paginate through all matching rows from DB.
+ var all []store.Transaction
+ var cursor uint64
+ for {
+ page, err := h.projectStore.TransactionsPage(cursor, f)
+ if err != nil {
+ fyne.Do(func() {
+ dialog.ShowError(fmt.Errorf("HAR export failed: %w", err), h.win)
+ })
+ return
+ }
+ if len(page) == 0 {
+ break
+ }
+ all = append(all, page...)
+ if len(page) < store.PageSize {
+ break
+ }
+ cursor = page[len(page)-1].ID
+ }
+
+ if len(all) == 0 {
+ fyne.Do(func() {
+ dialog.ShowInformation("Export HAR", "No transactions to export.", h.win)
+ })
+ return
+ }
+
+ // Fetch full bodies — page rows omit bodies for performance.
+ full := make([]store.Transaction, 0, len(all))
+ for _, tx := range all {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("har: get transaction %d: %v", tx.ID, err)
+ full = append(full, tx)
+ continue
+ }
+ full = append(full, *fullTx)
+ }
+
+ data, err := store.ExportHAR(full)
+ if err != nil {
+ fyne.Do(func() {
+ dialog.ShowError(fmt.Errorf("HAR export failed: %w", err), h.win)
+ })
+ return
+ }
+
+ fyne.Do(func() {
+ saveDialog := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
+ if err != nil || wc == nil {
+ return
+ }
+ defer wc.Close()
+ if _, err := wc.Write(data); err != nil {
+ logger.Error("har: write: %v", err)
+ dialog.ShowError(err, h.win)
+ }
+ }, h.win)
+ saveDialog.SetFileName(fmt.Sprintf("shiv-%s.har", time.Now().Format("2006-01-02-150405")))
+ saveDialog.Show()
+ })
+ }()
+}
diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go
new file mode 100644
index 0000000..6b706d4
--- /dev/null
+++ b/internal/ui/helpers.go
@@ -0,0 +1,91 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/widget"
+ "github.com/shiv/internal/logger"
+)
+
+func closeOnEscape(win fyne.Window, closeFn func()) {
+ previous := win.Canvas().OnTypedKey()
+ win.Canvas().SetOnTypedKey(func(keyEvent *fyne.KeyEvent) {
+ if keyEvent.Name == fyne.KeyEscape {
+ closeFn()
+ win.Canvas().SetOnTypedKey(previous)
+ }
+ })
+}
+
+// newBoldLabel returns a label with bold text style.
+func newBoldLabel(text string) *widget.Label {
+ label := widget.NewLabel(text)
+ label.TextStyle = fyne.TextStyle{Bold: true}
+ return label
+}
+
+// recoverPanic logs a panic without crashing the process.
+// Use as: defer recoverPanic("context description")
+func recoverPanic(context string) {
+ if r := recover(); r != nil {
+ logger.Error("panic in %s: %v", context, r)
+ }
+}
+
+func hasSuffix(s string, suffixes ...string) bool {
+ for _, suffix := range suffixes {
+ if strings.HasSuffix(s, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+// saveToFile opens a file-save dialog and writes content to the chosen path.
+func saveToFile(content []byte, defaultName string, win fyne.Window) {
+ d := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
+ if err != nil || wc == nil {
+ return
+ }
+ defer wc.Close()
+ if _, err := wc.Write(content); err != nil {
+ dialog.ShowError(err, win)
+ }
+ }, win)
+ d.SetFileName(defaultName)
+ d.Show()
+}
+
+// copyButton returns a button labelled "Copy" that copies text to clipboard.
+func copyButton(label string, text func() string) *widget.Button {
+ return widget.NewButton(label, func() {
+ fyne.CurrentApp().Clipboard().SetContent(text())
+ })
+}
+
+// paneHeader returns a border container with a bold label on the left and
+// optional extra widgets on the right, used consistently across all detail panes.
+func paneHeader(title string, right ...fyne.CanvasObject) fyne.CanvasObject {
+ var rightObj fyne.CanvasObject
+ if len(right) == 1 {
+ rightObj = right[0]
+ } else if len(right) > 1 {
+ rightObj = container.NewHBox(right...)
+ }
+ return container.NewBorder(nil, nil, newBoldLabel(title), rightObj)
+}
+
+func formatSize(bytes int) string {
+ switch {
+ case bytes >= 1<<20:
+ return fmt.Sprintf("%.1fMB", float64(bytes)/float64(1<<20))
+ case bytes >= 1<<10:
+ return fmt.Sprintf("%.1fKB", float64(bytes)/float64(1<<10))
+ default:
+ return fmt.Sprintf("%dB", bytes)
+ }
+}
diff --git a/internal/ui/history.go b/internal/ui/history.go
new file mode 100644
index 0000000..18ead77
--- /dev/null
+++ b/internal/ui/history.go
@@ -0,0 +1,1303 @@
+package ui
+
+import (
+ "fmt"
+ "image/color"
+ "net"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/widget"
+
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+type siteMapNode struct {
+ children map[string]*siteMapNode
+}
+
+func newSiteMapNode() *siteMapNode {
+ return &siteMapNode{children: make(map[string]*siteMapNode)}
+}
+
+type siteMap struct {
+ mu sync.RWMutex
+ hosts map[string]*siteMapNode
+ scopeFilter func(host string) bool
+ textFilter string
+}
+
+func newSiteMap() *siteMap {
+ return &siteMap{hosts: make(map[string]*siteMapNode)}
+}
+
+func (sm *siteMap) add(tx store.Transaction) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ key := hostPortKey(tx.Host, tx.Port)
+ if _, ok := sm.hosts[key]; !ok {
+ sm.hosts[key] = newSiteMapNode()
+ }
+ node := sm.hosts[key]
+ for _, seg := range splitPath(treePathOf(tx.URL)) {
+ if _, ok := node.children[seg]; !ok {
+ node.children[seg] = newSiteMapNode()
+ }
+ node = node.children[seg]
+ }
+}
+
+func (sm *siteMap) clear() {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.hosts = make(map[string]*siteMapNode)
+}
+
+func (sm *siteMap) childUIDs(uid widget.TreeNodeID) []widget.TreeNodeID {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+ if uid == "" {
+ ids := make([]widget.TreeNodeID, 0, len(sm.hosts))
+ for key := range sm.hosts {
+ // key is "host:port"; extract bare host for scope and text filter checks.
+ h, portStr, err := net.SplitHostPort(key)
+ if err != nil || h == "" {
+ h = key
+ }
+ if sm.scopeFilter != nil && !sm.scopeFilter(h) {
+ continue
+ }
+ if sm.textFilter != "" {
+ port, _ := strconv.Atoi(portStr)
+ display := strings.ToLower(displayHost(h, port))
+ if !strings.Contains(display, strings.ToLower(sm.textFilter)) {
+ continue
+ }
+ }
+ ids = append(ids, "h:"+key)
+ }
+ slices.Sort(ids)
+ return ids
+ }
+ host, port, path, ok := parseNodeID(uid)
+ if !ok {
+ return nil
+ }
+ node, ok := sm.hosts[hostPortKey(host, port)]
+ if !ok {
+ return nil
+ }
+ for _, seg := range splitPath(path) {
+ child, ok := node.children[seg]
+ if !ok {
+ return nil
+ }
+ node = child
+ }
+ ids := make([]widget.TreeNodeID, 0, len(node.children))
+ for seg := range node.children {
+ var childPath string
+ if path == "" {
+ childPath = "/" + seg
+ } else {
+ childPath = path + "/" + seg
+ }
+ ids = append(ids, "p:"+hostPortKey(host, port)+"|"+childPath)
+ }
+ slices.Sort(ids)
+ return ids
+}
+
+func (sm *siteMap) isBranch(uid widget.TreeNodeID) bool {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+ if uid == "" {
+ return true
+ }
+ host, port, path, ok := parseNodeID(uid)
+ if !ok {
+ return false
+ }
+ node, ok := sm.hosts[hostPortKey(host, port)]
+ if !ok {
+ return false
+ }
+ for _, seg := range splitPath(path) {
+ child, ok := node.children[seg]
+ if !ok {
+ return false
+ }
+ node = child
+ }
+ return len(node.children) > 0
+}
+
+func labelFor(uid widget.TreeNodeID) string {
+ host, port, path, ok := parseNodeID(uid)
+ if !ok {
+ return uid
+ }
+ if path == "" {
+ return displayHost(host, port)
+ }
+ segs := splitPath(path)
+ return "/" + segs[len(segs)-1]
+}
+
+// parseNodeID decodes a tree node UID into its components.
+// Host nodes: "h:example.com:1337" → host="example.com", port=1337, path=""
+// Path nodes: "p:example.com:1337|/api/v1" → host="example.com", port=1337, path="/api/v1"
+func parseNodeID(uid widget.TreeNodeID) (host string, port int, path string, ok bool) {
+ switch {
+ case strings.HasPrefix(uid, "h:"):
+ h, p, err := net.SplitHostPort(uid[2:])
+ if err != nil {
+ // Backward-compat: bare hostname with no port (old DB entries).
+ return uid[2:], 0, "", true
+ }
+ port, _ = strconv.Atoi(p)
+ return h, port, "", true
+ case strings.HasPrefix(uid, "p:"):
+ hostPort, p, ok0 := strings.Cut(uid[2:], "|")
+ if !ok0 {
+ return "", 0, "", false
+ }
+ h, portStr, err := net.SplitHostPort(hostPort)
+ if err != nil {
+ return hostPort, 0, p, true
+ }
+ port, _ = strconv.Atoi(portStr)
+ return h, port, p, true
+ }
+ return "", 0, "", false
+}
+
+// hostPortKey is the internal site-map map key for a host+port pair.
+// It is always "host:port" regardless of whether the port is default.
+func hostPortKey(host string, port int) string {
+ return net.JoinHostPort(host, strconv.Itoa(port))
+}
+
+// displayHost formats a host+port for display. Default ports (80, 443) are
+// omitted since they add no information and clutter the site map.
+func displayHost(host string, port int) string {
+ if port == 0 || port == 80 || port == 443 {
+ return host
+ }
+ return net.JoinHostPort(host, strconv.Itoa(port))
+}
+
+func splitPath(path string) []string {
+ var segs []string
+ for s := range strings.SplitSeq(path, "/") {
+ if s != "" {
+ segs = append(segs, s)
+ }
+ }
+ return segs
+}
+
+// treePathOf returns the path portion of a URL with query string stripped.
+// Used for site map tree nodes so that /api?page=1 and /api?page=2 map to
+// the same /api node instead of creating separate leaves per unique query.
+func treePathOf(rawURL string) string {
+ p := PathOf(rawURL)
+ if idx := strings.IndexByte(p, '?'); idx >= 0 {
+ return p[:idx]
+ }
+ return p
+}
+
+var staticExtensions = []string{
+ ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
+ ".woff", ".woff2", ".ttf", ".eot", ".map", ".webp", ".avif",
+}
+
+func matchesFilter(tx store.Transaction, f store.TransactionFilter) bool {
+ if f.Host != "" && tx.Host != f.Host {
+ return false
+ }
+ if f.Port != 0 && tx.Port != f.Port {
+ return false
+ }
+ if f.PathPrefix != "" {
+ p := treePathOf(tx.URL)
+ if p != f.PathPrefix && !strings.HasPrefix(p, f.PathPrefix+"/") {
+ return false
+ }
+ }
+ if !f.ShowOutScope && !tx.InScope {
+ return false
+ }
+ if len(f.Methods) > 0 {
+ found := false
+ for _, m := range f.Methods {
+ if strings.EqualFold(tx.Method, m) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ if f.StatusMin > 0 && tx.StatusCode < f.StatusMin {
+ return false
+ }
+ if f.StatusMax > 0 && tx.StatusCode > f.StatusMax {
+ return false
+ }
+ if f.HideStatic {
+ p := strings.ToLower(treePathOf(tx.URL))
+ for _, ext := range staticExtensions {
+ if strings.HasSuffix(p, ext) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+type historyTab struct {
+ projectStore *store.Store
+ win fyne.Window
+ repeater *repeaterTab
+ loot *lootTab
+ intruder *intruderTab
+
+ mu sync.RWMutex
+
+ // displayed holds the rows currently shown in the table.
+ // New traffic is prepended; older rows are appended via infinite scroll.
+ displayed []store.Transaction
+
+ // cursor is the lowest ID in displayed. The next page loads id < cursor.
+ cursor uint64
+
+ // loadingMore prevents concurrent next-page loads.
+ loadingMore bool
+
+ // hasMore is false when the DB has no more rows below the cursor.
+ hasMore bool
+
+ // queryID increments on every loadFirstPage call. Goroutines check it
+ // before writing results so stale responses from old queries are dropped.
+ queryID uint64
+
+ siteMap *siteMap
+ tree *widget.Tree
+ selectedUID widget.TreeNodeID
+
+ table *widgets.DataTable
+ filterEntry *widget.Entry
+ treeFilterEntry *widget.Entry
+ showOutScope *widget.Check
+ methodSelect *widget.Select
+ statusSelect *widget.Select
+ hideStatic *widget.Check
+ reqLabel *widgets.TextView
+ respLabel *widgets.TextView
+ scopeBtn *widget.Button
+ deleteBtn *widget.Button
+ reqCopyBtn *widget.Button
+ respCopyBtn *widget.Button
+ respViewFullBtn *widget.Button
+
+ selectedTx store.Transaction
+ hasSelected bool
+
+ annotationsMu sync.RWMutex
+ annotations map[uint64]store.Annotation
+}
+
+var tableColumns = []widgets.DataTableColumn{
+ {Header: "Method", Width: 80},
+ {Header: "Host", Width: 180},
+ {Header: "Path", Width: 260},
+ {Header: "Status", Width: 70},
+ {Header: "Proto", Width: 80},
+ {Header: "Size", Width: 80},
+ {Header: "Duration", Width: 80},
+ {Header: "Time", Width: 160},
+ {Header: "Comment", Width: 160},
+}
+
+func newHistoryTab(projectStore *store.Store, win fyne.Window, repeater *repeaterTab, loot *lootTab, intruder *intruderTab) *historyTab {
+ return &historyTab{
+ projectStore: projectStore,
+ win: win,
+ repeater: repeater,
+ loot: loot,
+ intruder: intruder,
+ siteMap: newSiteMap(),
+ hasMore: true,
+ annotations: make(map[uint64]store.Annotation),
+ }
+}
+
+func (h *historyTab) currentFilter() store.TransactionFilter {
+ var f store.TransactionFilter
+ if h.filterEntry != nil {
+ f.Search = h.filterEntry.Text
+ }
+ if h.showOutScope != nil {
+ f.ShowOutScope = h.showOutScope.Checked
+ }
+ if h.hideStatic != nil {
+ f.HideStatic = h.hideStatic.Checked
+ }
+ if h.methodSelect != nil {
+ if sel := h.methodSelect.Selected; sel != "" && sel != "All" {
+ f.Methods = []string{sel}
+ }
+ }
+ if h.statusSelect != nil {
+ switch h.statusSelect.Selected {
+ case "2xx":
+ f.StatusMin, f.StatusMax = 200, 299
+ case "3xx":
+ f.StatusMin, f.StatusMax = 300, 399
+ case "4xx":
+ f.StatusMin, f.StatusMax = 400, 499
+ case "5xx":
+ f.StatusMin, f.StatusMax = 500, 599
+ }
+ }
+
+ if h.selectedUID != "" {
+ host, port, path, ok := parseNodeID(h.selectedUID)
+ if ok {
+ f.Host = host
+ f.Port = port
+ f.PathPrefix = path
+ }
+ }
+ return f
+}
+
+// fetchPage fetches a page of transactions from the DB and applies
+// path-prefix filtering. Returns filtered results and unfiltered count.
+func (h *historyTab) fetchPage(cursor uint64, f store.TransactionFilter) ([]store.Transaction, int, error) {
+ txs, err := h.projectStore.TransactionsPage(cursor, f)
+ if err != nil {
+ return nil, 0, err
+ }
+ fullLen := len(txs)
+ if f.PathPrefix != "" || f.HideStatic {
+ filtered := make([]store.Transaction, 0, len(txs))
+ for _, tx := range txs {
+ if matchesFilter(tx, f) {
+ filtered = append(filtered, tx)
+ }
+ }
+ txs = filtered
+ }
+ return txs, fullLen, nil
+}
+
+func (h *historyTab) loadFirstPage() {
+ f := h.currentFilter()
+ h.mu.Lock()
+ h.queryID++
+ localID := h.queryID
+ h.mu.Unlock()
+
+ go func() {
+ txs, fullLen, err := h.fetchPage(0, f)
+ if err != nil {
+ logger.Error("history: load first page: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ h.mu.Lock()
+ if localID != h.queryID {
+ h.mu.Unlock()
+ return
+ }
+ h.loadingMore = false
+ h.displayed = txs
+ h.hasMore = fullLen == store.PageSize
+ if len(txs) > 0 {
+ h.cursor = txs[len(txs)-1].ID
+ } else {
+ h.cursor = 0
+ h.hasMore = false
+ }
+ h.mu.Unlock()
+ h.table.ScrollToRow(0)
+ })
+ }()
+}
+
+func (h *historyTab) loadNextPage() {
+ h.mu.Lock()
+ if h.loadingMore || !h.hasMore || h.cursor == 0 {
+ h.mu.Unlock()
+ return
+ }
+ cursor := h.cursor
+ localID := h.queryID
+ h.loadingMore = true
+ h.mu.Unlock()
+
+ f := h.currentFilter()
+ go func() {
+ txs, fullLen, err := h.fetchPage(cursor, f)
+ if err != nil {
+ logger.Error("history: load next page: %v", err)
+ h.mu.Lock()
+ h.loadingMore = false
+ h.mu.Unlock()
+ return
+ }
+ fyne.Do(func() {
+ h.mu.Lock()
+ if localID != h.queryID {
+ h.loadingMore = false
+ h.mu.Unlock()
+ return
+ }
+ h.displayed = append(h.displayed, txs...)
+ h.hasMore = fullLen == store.PageSize
+ if len(txs) > 0 {
+ h.cursor = txs[len(txs)-1].ID
+ }
+ h.loadingMore = false
+ h.mu.Unlock()
+ h.table.Refresh()
+ })
+ }()
+}
+
+func (h *historyTab) buildFilterBar() fyne.CanvasObject {
+ h.filterEntry = widget.NewEntry()
+ h.filterEntry.SetPlaceHolder("Filter — host, path, method, status...")
+ h.filterEntry.OnChanged = func(_ string) { h.loadFirstPage() }
+
+ h.methodSelect = widget.NewSelect(
+ []string{"All", "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"},
+ func(_ string) { h.loadFirstPage() },
+ )
+ h.methodSelect.SetSelected("All")
+
+ h.statusSelect = widget.NewSelect(
+ []string{"All", "2xx", "3xx", "4xx", "5xx"},
+ func(_ string) { h.loadFirstPage() },
+ )
+ h.statusSelect.SetSelected("All")
+
+ h.hideStatic = widget.NewCheck("Hide static", func(_ bool) { h.loadFirstPage() })
+
+ h.showOutScope = widget.NewCheck("Show out-of-scope", func(checked bool) {
+ if checked {
+ h.siteMap.scopeFilter = nil
+ } else {
+ h.siteMap.scopeFilter = func(host string) bool {
+ return h.projectStore.InScope(host)
+ }
+ }
+ h.tree.ScrollToTop()
+ h.tree.Refresh()
+ h.loadFirstPage()
+ })
+ h.showOutScope.Checked = true
+
+ exportHARBtn := widget.NewButtonWithIcon("Export HAR", AppIcon("document"), func() {
+ h.exportHAR()
+ })
+ wsBtn := widget.NewButtonWithIcon("WebSockets", AppIcon("web"), func() {
+ showWebSocketWindow(fyne.CurrentApp(), h.projectStore, h.win, h.repeater)
+ })
+ clearBtn := widget.NewButtonWithIcon("Clear", AppIcon("delete"), func() {
+ h.clearHistory()
+ })
+
+ filterRow2 := container.NewBorder(nil, nil, nil,
+ container.NewHBox(h.showOutScope, h.hideStatic, exportHARBtn, wsBtn, clearBtn),
+ container.NewHBox(
+ widget.NewLabel("Method:"), h.methodSelect,
+ widget.NewLabel("Status:"), h.statusSelect,
+ ),
+ )
+ return container.NewVBox(
+ container.NewBorder(nil, nil, nil, nil, h.filterEntry),
+ filterRow2,
+ )
+}
+
+func (h *historyTab) deleteSelectedHost() {
+ if h.selectedUID == "" || !strings.HasPrefix(h.selectedUID, "h:") {
+ return
+ }
+ host, port, _, ok := parseNodeID(h.selectedUID)
+ if !ok {
+ return
+ }
+ if err := h.projectStore.DeleteTransactionsByHostPort(host, port); err != nil {
+ logger.Error("history: delete %s:%d: %v", host, port, err)
+ return
+ }
+ h.siteMap.mu.Lock()
+ delete(h.siteMap.hosts, hostPortKey(host, port))
+ h.siteMap.mu.Unlock()
+ h.selectedUID = ""
+ h.mu.Lock()
+ h.displayed = nil
+ h.cursor = 0
+ h.hasMore = true
+ h.mu.Unlock()
+ h.tree.UnselectAll()
+ h.tree.Refresh()
+ h.deleteBtn.Disable()
+ h.loadFirstPage()
+}
+
+func (h *historyTab) updateTreeNode(uid widget.TreeNodeID, branch bool, obj fyne.CanvasObject) {
+ box := obj.(*fyne.Container)
+ icon := box.Objects[0].(*widget.Icon)
+ label := box.Objects[1].(*widget.Label)
+ if strings.HasPrefix(uid, "h:") {
+ icon.SetResource(AppIcon("web"))
+ } else if branch {
+ icon.SetResource(AppIcon("folder"))
+ } else if hasSuffix(labelFor(uid), ".jpg", ".jpeg", ".png", ".svg", ".gif") {
+ icon.SetResource(AppIcon("media"))
+ } else {
+ icon.SetResource(AppIcon("document"))
+ }
+ label.SetText(labelFor(uid))
+}
+
+func (h *historyTab) buildSiteMap() fyne.CanvasObject {
+ h.scopeBtn = widget.NewButtonWithIcon("Scope", AppIcon("scope"), func() {
+ showScopeDialog(h.projectStore, h.win, func() {
+ h.tree.Refresh()
+ h.loadFirstPage()
+ })
+ })
+
+ h.deleteBtn = widget.NewButtonWithIcon("", AppIcon("delete"), func() {
+ h.deleteSelectedHost()
+ })
+ h.deleteBtn.Disable()
+
+ h.tree = widget.NewTree(
+ h.siteMap.childUIDs,
+ h.siteMap.isBranch,
+ func(_ bool) fyne.CanvasObject {
+ return container.NewHBox(widget.NewIcon(nil), widget.NewLabel("template"))
+ },
+ h.updateTreeNode,
+ )
+ h.tree.OnSelected = func(uid widget.TreeNodeID) {
+ h.selectedUID = uid
+ if strings.HasPrefix(uid, "h:") {
+ h.deleteBtn.Enable()
+ } else {
+ h.deleteBtn.Disable()
+ }
+ h.loadFirstPage()
+ }
+
+ h.treeFilterEntry = widget.NewEntry()
+ h.treeFilterEntry.SetPlaceHolder("Filter hosts...")
+ h.treeFilterEntry.OnChanged = func(text string) {
+ h.siteMap.mu.Lock()
+ h.siteMap.textFilter = text
+ h.siteMap.mu.Unlock()
+ h.tree.Refresh()
+ }
+
+ allBtn := widget.NewButton("All", func() {
+ h.selectedUID = ""
+ h.tree.UnselectAll()
+ h.deleteBtn.Disable()
+ h.loadFirstPage()
+ })
+
+ siteMapHeader := container.NewHBox(newBoldLabel("Site Map"), layout.NewSpacer(), allBtn, h.deleteBtn, h.scopeBtn)
+ return container.NewBorder(
+ container.NewVBox(siteMapHeader, h.treeFilterEntry),
+ nil, nil, nil,
+ h.tree,
+ )
+}
+
+func (h *historyTab) tableCellStyle(row, col int) widget.Importance {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ if row >= len(h.displayed) {
+ return widget.MediumImportance
+ }
+ tx := h.displayed[row]
+ switch col {
+ case 0:
+ switch tx.Method {
+ case "POST", "PUT", "PATCH", "DELETE":
+ return widget.WarningImportance
+ }
+ case 3:
+ switch {
+ case tx.StatusCode >= 500:
+ return widget.DangerImportance
+ case tx.StatusCode >= 400:
+ return widget.WarningImportance
+ case tx.StatusCode >= 300:
+ return widget.LowImportance
+ case tx.StatusCode >= 200:
+ return widget.SuccessImportance
+ }
+ }
+ return widget.MediumImportance
+}
+
+func (h *historyTab) tableOnSelect(row int) {
+ h.mu.RLock()
+ if row >= len(h.displayed) {
+ h.mu.RUnlock()
+ return
+ }
+ tx := h.displayed[row]
+ h.mu.RUnlock()
+ h.selectedTx = tx
+ h.hasSelected = true
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: get transaction: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ h.selectedTx = *fullTx
+ h.showDetail(*fullTx)
+ })
+ }()
+}
+
+func (h *historyTab) tableRowBgColor(row int) color.Color {
+ h.mu.RLock()
+ if row >= len(h.displayed) {
+ h.mu.RUnlock()
+ return nil
+ }
+ id := h.displayed[row].ID
+ h.mu.RUnlock()
+ h.annotationsMu.RLock()
+ a, ok := h.annotations[id]
+ h.annotationsMu.RUnlock()
+ if !ok || a.Color == "" {
+ return nil
+ }
+ return annotationColor(a.Color)
+}
+
+func (h *historyTab) buildTable() fyne.CanvasObject {
+ h.table = widgets.NewDataTable()
+ h.table.SetWindow(h.win)
+ h.table.Columns = tableColumns
+ h.table.RowCount = func() int {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return len(h.displayed)
+ }
+ h.table.CellValue = func(row, col int) string {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ if row >= len(h.displayed) {
+ return ""
+ }
+ return h.cellText(h.displayed[row], col)
+ }
+ h.table.CellStyle = h.tableCellStyle
+ h.table.RowID = func(row int) int64 {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ if row >= len(h.displayed) {
+ return 0
+ }
+ return int64(h.displayed[row].ID)
+ }
+ h.table.OnSelect = h.tableOnSelect
+ h.table.MenuItems = func(row int) []widgets.ContextMenuItem {
+ h.mu.RLock()
+ if row >= len(h.displayed) {
+ h.mu.RUnlock()
+ return nil
+ }
+ tx := h.displayed[row]
+ h.mu.RUnlock()
+ return h.contextMenuItems(tx)
+ }
+ h.table.OnNearBottom = func() {
+ h.loadNextPage()
+ }
+ h.table.RowBgColor = h.tableRowBgColor
+ return h.table.Build()
+}
+
+func (h *historyTab) buildDetailPane() fyne.CanvasObject {
+ h.reqLabel = widgets.NewTextView()
+ h.reqLabel.SetWindow(h.win)
+ h.respLabel = widgets.NewTextView()
+ h.respLabel.SetWindow(h.win)
+
+ h.reqCopyBtn = widget.NewButton("Copy", func() {
+ fyne.CurrentApp().Clipboard().SetContent(FormatStoreRequest(h.selectedTx))
+ })
+ h.reqCopyBtn.Disable()
+
+ h.respCopyBtn = widget.NewButton("Copy", func() {
+ fyne.CurrentApp().Clipboard().SetContent(FormatStoreResponse(h.selectedTx))
+ })
+ h.respCopyBtn.Disable()
+
+ h.respViewFullBtn = widget.NewButton("View Full", func() {
+ showFullBodyDialog(h.selectedTx.RespBody, h.win)
+ })
+ h.respViewFullBtn.Disable()
+ h.respViewFullBtn.Hide()
+
+ inspectBtn := widget.NewButtonWithIcon("Inspector", AppIcon("inspector"), func() {
+ if !h.hasSelected {
+ return
+ }
+ showInspectorDialog(h.selectedTx, h.win)
+ })
+
+ reqPane := container.NewBorder(
+ paneHeader("Request", h.reqCopyBtn),
+ nil, nil, nil, h.reqLabel.Build(),
+ )
+ respPane := container.NewBorder(
+ paneHeader("Response", h.respViewFullBtn, h.respCopyBtn, inspectBtn),
+ nil, nil, nil,
+ h.respLabel.Build(),
+ )
+
+ detailSplit := container.NewHSplit(reqPane, respPane)
+ detailSplit.SetOffset(0.5)
+ return detailSplit
+}
+
+func (h *historyTab) loadSiteMapEntries() {
+ entries, err := h.projectStore.SiteMapEntries()
+ if err != nil {
+ logger.Error("history: build site map: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ for _, e := range entries {
+ h.siteMap.add(store.Transaction{Host: e.Host, Port: e.Port, URL: e.URL})
+ }
+ h.tree.Refresh()
+ })
+}
+
+func (h *historyTab) loadAnnotations() {
+ all, err := h.projectStore.AllAnnotations()
+ if err != nil {
+ logger.Error("history: load annotations: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ h.annotationsMu.Lock()
+ for _, a := range all {
+ h.annotations[a.HistoryID] = a
+ }
+ h.annotationsMu.Unlock()
+ h.table.Refresh()
+ })
+}
+
+func (h *historyTab) clearHistory() {
+ if err := h.projectStore.ClearHistory(); err != nil {
+ logger.Error("clear history: %v", err)
+ return
+ }
+ h.mu.Lock()
+ h.displayed = nil
+ h.cursor = 0
+ h.hasMore = false
+ h.mu.Unlock()
+ h.siteMap.clear()
+ h.selectedUID = ""
+ h.tree.Refresh()
+ h.table.Refresh()
+ h.selectedTx = store.Transaction{}
+ h.hasSelected = false
+ h.reqLabel.SetText("")
+ h.respLabel.SetText("")
+}
+
+func (h *historyTab) build() fyne.CanvasObject {
+ filterBar := h.buildFilterBar()
+ siteMapPane := h.buildSiteMap()
+ tableObj := h.buildTable()
+ detailSplit := h.buildDetailPane()
+
+ topSplit := container.NewHSplit(
+ siteMapPane,
+ container.NewBorder(filterBar, nil, nil, nil, tableObj),
+ )
+ topSplit.SetOffset(0.25)
+
+ mainSplit := container.NewVSplit(topSplit, detailSplit)
+ mainSplit.SetOffset(0.55)
+
+ go h.loadSiteMapEntries()
+ go h.loadAnnotations()
+ h.loadFirstPage()
+ go h.watchUpdates()
+
+ return mainSplit
+}
+
+func (h *historyTab) watchUpdates() {
+ defer recoverPanic("watchUpdates")
+ for tx := range h.projectStore.Updates {
+ fyne.Do(func() {
+ h.mu.Lock()
+ for _, r := range h.displayed {
+ if r.ID == tx.ID {
+ h.mu.Unlock()
+ return
+ }
+ }
+ f := h.currentFilter()
+ show := matchesFilter(tx, f)
+ if show {
+ h.displayed = append([]store.Transaction{tx}, h.displayed...)
+ h.cursor = h.displayed[len(h.displayed)-1].ID
+ }
+ isSelected := h.hasSelected && h.selectedTx.ID == tx.ID
+ if isSelected {
+ h.selectedTx = tx
+ }
+ h.mu.Unlock()
+ h.siteMap.add(tx)
+ h.tree.Refresh()
+ if show {
+ h.table.Refresh()
+ }
+ if isSelected {
+ h.showDetail(tx)
+ }
+ })
+ }
+}
+
+func (h *historyTab) showDetail(tx store.Transaction) {
+ truncated := len(tx.RespBody) > internalhttp.MaxDisplayBytes
+ display := tx
+ display.RespBody = internalhttp.TruncateBody(tx.RespBody)
+ display.ReqBody = internalhttp.TruncateBody(tx.ReqBody)
+ h.reqLabel.SetText(FormatStoreRequest(display))
+ h.respLabel.SetText(FormatStoreResponse(display))
+
+ h.reqCopyBtn.Enable()
+ h.respCopyBtn.Enable()
+ if truncated {
+ h.respViewFullBtn.Show()
+ h.respViewFullBtn.Enable()
+ } else {
+ h.respViewFullBtn.Hide()
+ }
+}
+
+func showFullBodyDialog(body []byte, win fyne.Window) {
+ tv := widgets.NewTextView()
+ tv.SetWindow(win)
+ tv.SetText(string(body))
+ d := dialog.NewCustom("Full Response Body", "Close", tv.Build(), win)
+ d.Resize(fyne.NewSize(800, 600))
+ closeOnEscape(win, d.Dismiss)
+ d.Show()
+}
+
+func (h *historyTab) annotationMenuItems(tx store.Transaction) []widgets.ContextMenuItem {
+ return []widgets.ContextMenuItem{
+ {
+ Label: "Add Comment...",
+ Action: func() { h.showCommentDialog(tx.ID) },
+ },
+ {
+ Label: "Highlight",
+ Action: func() { h.showHighlightMenu(tx.ID) },
+ },
+ {
+ Label: "Clear Annotation",
+ Action: func() {
+ if err := h.projectStore.DeleteAnnotation(tx.ID); err != nil {
+ logger.Error("history: clear annotation: %v", err)
+ return
+ }
+ h.annotationsMu.Lock()
+ delete(h.annotations, tx.ID)
+ h.annotationsMu.Unlock()
+ h.table.Refresh()
+ },
+ },
+ }
+}
+
+func (h *historyTab) contextMenuItems(tx store.Transaction) []widgets.ContextMenuItem {
+ items := []widgets.ContextMenuItem{
+ {
+ Label: "Send to Repeater",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("send to repeater: %v", err)
+ return
+ }
+ fyne.Do(func() { h.sendToRepeater(*fullTx) })
+ }()
+ },
+ },
+ {Label: "Send to Intruder", Action: func() { h.sendToIntruder(tx) }},
+ {
+ Label: "Send to Loot",
+ Action: func() {
+ id := tx.ID
+ h.loot.showAddDialog(&id, "", "")
+ },
+ },
+ {Label: "Copy URL", Action: func() { fyne.CurrentApp().Clipboard().SetContent(tx.URL) }},
+ {
+ Label: "Copy Request",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: copy request: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ fyne.CurrentApp().Clipboard().SetContent(FormatStoreRequest(*fullTx))
+ })
+ }()
+ },
+ },
+ {
+ Label: "Copy Response",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: copy response: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ fyne.CurrentApp().Clipboard().SetContent(FormatStoreResponse(*fullTx))
+ })
+ }()
+ },
+ },
+ {
+ Label: "Save Request...",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: save request: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ saveToFile([]byte(FormatStoreRequest(*fullTx)), "request.txt", h.win)
+ })
+ }()
+ },
+ },
+ {
+ Label: "Save Response...",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: save response: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ saveResponseToFile(*fullTx, h.win)
+ })
+ }()
+ },
+ },
+ {
+ Label: "Generate CSRF PoC",
+ Action: func() {
+ go func() {
+ fullTx, err := h.projectStore.GetTransaction(tx.ID)
+ if err != nil {
+ logger.Error("history: csrf poc: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ showCSRFPoCDialog(*fullTx, h.win)
+ })
+ }()
+ },
+ },
+ }
+ return append(items, h.annotationMenuItems(tx)...)
+}
+
+func (h *historyTab) sendToRepeater(tx store.Transaction) {
+ path := PathOf(tx.URL)
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ h.repeater.AddTab(fmt.Sprintf("%s %s", tx.Method, path), tx.Host, tx.Port, tx.TLS, FormatStoreRequest(tx))
+}
+
+func (h *historyTab) sendToIntruder(tx store.Transaction) {
+ h.intruder.reqEditor.SetText(FormatStoreRequest(tx))
+}
+
+func protoLabel(tx store.Transaction) string {
+ if tx.TLS {
+ if tx.Proto == "HTTP/2" {
+ return "H2 TLS"
+ }
+ return "H1 TLS"
+ }
+ if tx.Proto == "HTTP/2" {
+ return "H2"
+ }
+ return "H1.1"
+}
+
+func (h *historyTab) cellText(tx store.Transaction, col int) string {
+ switch col {
+ case 0:
+ return tx.Method
+ case 1:
+ return displayHost(tx.Host, tx.Port)
+ case 2:
+ return PathOf(tx.URL)
+ case 3:
+ return fmt.Sprintf("%d", tx.StatusCode)
+ case 4:
+ return protoLabel(tx)
+ case 5:
+ return formatSize(tx.RespSize)
+ case 6:
+ return fmt.Sprintf("%dms", tx.DurationMs)
+ case 7:
+ return tx.Timestamp.Local().Format("2006-01-02 15:04:05")
+ case 8:
+ h.annotationsMu.RLock()
+ a, ok := h.annotations[tx.ID]
+ h.annotationsMu.RUnlock()
+ if ok {
+ return a.Comment
+ }
+ return ""
+ }
+ return ""
+}
+
+func saveResponseToFile(tx store.Transaction, win fyne.Window) {
+ ext := ".txt"
+ ct := tx.RespHeaders.Get("Content-Type")
+ switch {
+ case strings.Contains(ct, "application/json"):
+ ext = ".json"
+ case strings.Contains(ct, "text/html"):
+ ext = ".html"
+ case strings.Contains(ct, "image/png"):
+ ext = ".png"
+ case strings.Contains(ct, "image/jpeg"):
+ ext = ".jpg"
+ case strings.Contains(ct, "application/pdf"):
+ ext = ".pdf"
+ }
+ defaultName := "response" + ext
+
+ // Try to derive filename from URL path.
+ if base := filepath.Base(strings.TrimRight(treePathOf(tx.URL), "/")); base != "" && base != "." && base != "/" {
+ if filepath.Ext(base) != "" {
+ defaultName = base
+ }
+ }
+
+ // Write raw bytes (not formatted) so binary content is preserved.
+ saveToFile(tx.RespBody, defaultName, win)
+}
+
+func showCSRFPoCDialog(tx store.Transaction, win fyne.Window) {
+ poc := generateCSRFPoC(tx)
+
+ tv := widgets.NewTextView()
+ tv.SetWindow(win)
+ tv.SetText(poc)
+
+ copyBtn := widget.NewButton("Copy HTML", func() {
+ fyne.CurrentApp().Clipboard().SetContent(poc)
+ })
+ saveBtn := widget.NewButton("Save as .html...", func() {
+ saveToFile([]byte(poc), "csrf-poc.html", win)
+ })
+
+ content := container.NewBorder(
+ container.NewHBox(copyBtn, saveBtn), nil, nil, nil,
+ tv.Build(),
+ )
+
+ d := dialog.NewCustom("CSRF PoC", "Close", content, win)
+ d.Resize(fyne.NewSize(700, 500))
+ closeOnEscape(win, d.Dismiss)
+ d.Show()
+}
+
+func generateCSRFPoC(tx store.Transaction) string {
+ action := tx.URL
+ method := strings.ToUpper(tx.Method)
+ if method == "" {
+ method = "POST"
+ }
+
+ var fields strings.Builder
+ ct := tx.ReqHeaders.Get("Content-Type")
+ if strings.Contains(ct, "application/x-www-form-urlencoded") && len(tx.ReqBody) > 0 {
+ // Parse form body and emit hidden inputs.
+ pairs := strings.Split(string(tx.ReqBody), "&")
+ for _, pair := range pairs {
+ kv := strings.SplitN(pair, "=", 2)
+ name := htmlAttrEscape(kv[0])
+ val := ""
+ if len(kv) == 2 {
+ val = htmlAttrEscape(kv[1])
+ }
+ fields.WriteString(fmt.Sprintf(" <input type=\"hidden\" name=\"%s\" value=\"%s\" />\n", name, val))
+ }
+ }
+
+ var b strings.Builder
+ b.WriteString("<!DOCTYPE html>\n<html>\n<body>\n")
+ b.WriteString(fmt.Sprintf("<form id=\"csrf\" action=\"%s\" method=\"%s\">\n", htmlAttrEscape(action), method))
+ b.WriteString(fields.String())
+ b.WriteString(" <input type=\"submit\" value=\"Submit\" />\n")
+ b.WriteString("</form>\n")
+ b.WriteString("<script>document.getElementById('csrf').submit();</script>\n")
+ b.WriteString("</body>\n</html>\n")
+ return b.String()
+}
+
+func htmlAttrEscape(s string) string {
+ s = strings.ReplaceAll(s, "&", "&amp;")
+ s = strings.ReplaceAll(s, "\"", "&quot;")
+ s = strings.ReplaceAll(s, "<", "&lt;")
+ s = strings.ReplaceAll(s, ">", "&gt;")
+ return s
+}
+
+func (h *historyTab) showCommentDialog(historyID uint64) {
+ h.annotationsMu.RLock()
+ existing := h.annotations[historyID].Comment
+ h.annotationsMu.RUnlock()
+
+ entry := widget.NewMultiLineEntry()
+ entry.SetText(existing)
+ entry.SetPlaceHolder("Enter comment...")
+ entry.Resize(fyne.NewSize(400, 120))
+
+ d := dialog.NewCustomConfirm("Add Comment", "Save", "Cancel",
+ container.NewStack(entry),
+ func(ok bool) {
+ if !ok {
+ return
+ }
+ h.annotationsMu.RLock()
+ a := h.annotations[historyID]
+ h.annotationsMu.RUnlock()
+ a.HistoryID = historyID
+ a.Comment = entry.Text
+ if err := h.projectStore.SetAnnotation(a); err != nil {
+ logger.Error("history: set annotation comment: %v", err)
+ return
+ }
+ h.annotationsMu.Lock()
+ h.annotations[historyID] = a
+ h.annotationsMu.Unlock()
+ h.table.Refresh()
+ }, h.win)
+ d.Resize(fyne.NewSize(440, 220))
+ closeOnEscape(h.win, d.Dismiss)
+ d.Show()
+}
+
+var highlightColors = []struct {
+ name string
+ color string
+}{
+ {"Red", "red"},
+ {"Orange", "orange"},
+ {"Yellow", "yellow"},
+ {"Green", "green"},
+ {"Blue", "blue"},
+ {"Purple", "purple"},
+}
+
+func (h *historyTab) showHighlightMenu(historyID uint64) {
+ items := make([]*widget.Button, 0, len(highlightColors)+1)
+ var d *dialog.CustomDialog
+ for _, hc := range highlightColors {
+ hc := hc
+ btn := widget.NewButton(hc.name, func() {
+ h.annotationsMu.RLock()
+ a := h.annotations[historyID]
+ h.annotationsMu.RUnlock()
+ a.HistoryID = historyID
+ a.Color = hc.color
+ if err := h.projectStore.SetAnnotation(a); err != nil {
+ logger.Error("history: set annotation color: %v", err)
+ return
+ }
+ h.annotationsMu.Lock()
+ h.annotations[historyID] = a
+ h.annotationsMu.Unlock()
+ h.table.Refresh()
+ d.Hide()
+ })
+ items = append(items, btn)
+ }
+ var objs []fyne.CanvasObject
+ for _, btn := range items {
+ objs = append(objs, btn)
+ }
+ content := container.NewVBox(objs...)
+ d = dialog.NewCustom("Highlight Color", "Cancel", content, h.win)
+ closeOnEscape(h.win, d.Dismiss)
+ d.Show()
+}
+
+func annotationColor(name string) color.Color {
+ switch name {
+ case "red":
+ return color.NRGBA{R: 220, G: 80, B: 80, A: 100}
+ case "orange":
+ return color.NRGBA{R: 230, G: 140, B: 50, A: 100}
+ case "yellow":
+ return color.NRGBA{R: 220, G: 200, B: 50, A: 100}
+ case "green":
+ return color.NRGBA{R: 60, G: 180, B: 80, A: 100}
+ case "blue":
+ return color.NRGBA{R: 60, G: 130, B: 220, A: 100}
+ case "purple":
+ return color.NRGBA{R: 160, G: 80, B: 200, A: 100}
+ }
+ return nil
+}
+
diff --git a/internal/ui/history_test.go b/internal/ui/history_test.go
new file mode 100644
index 0000000..b251460
--- /dev/null
+++ b/internal/ui/history_test.go
@@ -0,0 +1,124 @@
+package ui
+
+import (
+ "testing"
+
+ "github.com/shiv/internal/store"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTreePathOf(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ expected string
+ }{
+ {"absolute https no query", "https://example.com/api/v1", "/api/v1"},
+ {"absolute https with query", "https://example.com/api/v1?page=1&limit=10", "/api/v1"},
+ {"absolute http with query", "http://example.com/search?q=foo&lang=en", "/search"},
+ {"root with query", "https://example.com/?utm_source=x", "/"},
+ {"no path", "https://example.com", "/"},
+ {"path only no query", "/api/users", "/api/users"},
+ {"path only with query", "/api/users?id=123", "/api/users"},
+ {"root path with query", "/?q=foo", "/"},
+ {"empty string", "", ""},
+ {"query only after root", "https://example.com?x=1", "/"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, treePathOf(tt.url))
+ })
+ }
+}
+
+func TestSiteMap_Add_DeduplicatesQueryStrings(t *testing.T) {
+ sm := newSiteMap()
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api?page=1"})
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api?page=2"})
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api?page=3"})
+
+ hostUIDs := sm.childUIDs("")
+ assert.Len(t, hostUIDs, 1, "should have exactly one host")
+
+ pathUIDs := sm.childUIDs(hostUIDs[0])
+ assert.Len(t, pathUIDs, 1, "query strings must be deduplicated to a single /api node")
+}
+
+func TestSiteMap_Add_SeparatesByPort(t *testing.T) {
+ sm := newSiteMap()
+ sm.add(store.Transaction{Host: "example.com", Port: 80, URL: "http://example.com/"})
+ sm.add(store.Transaction{Host: "example.com", Port: 8080, URL: "http://example.com:8080/"})
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/"})
+
+ assert.Len(t, sm.childUIDs(""), 3, "each port must be a separate tree entry")
+}
+
+func TestSiteMap_TextFilter(t *testing.T) {
+ sm := newSiteMap()
+ sm.add(store.Transaction{Host: "example.com", Port: 80, URL: "http://example.com/"})
+ sm.add(store.Transaction{Host: "other.com", Port: 80, URL: "http://other.com/"})
+ sm.add(store.Transaction{Host: "test.example.com", Port: 80, URL: "http://test.example.com/"})
+
+ assert.Len(t, sm.childUIDs(""), 3, "no filter: all hosts visible")
+
+ sm.mu.Lock()
+ sm.textFilter = "example"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 2, "filter 'example': matches example.com and test.example.com")
+
+ sm.mu.Lock()
+ sm.textFilter = "other"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 1, "filter 'other': matches only other.com")
+
+ sm.mu.Lock()
+ sm.textFilter = "EXAMPLE"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 2, "filter must be case-insensitive")
+
+ sm.mu.Lock()
+ sm.textFilter = "xyz"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 0, "filter 'xyz': no matches")
+
+ sm.mu.Lock()
+ sm.textFilter = ""
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 3, "cleared filter: all hosts visible again")
+}
+
+func TestSiteMap_TextFilter_NonStandardPort(t *testing.T) {
+ sm := newSiteMap()
+ sm.add(store.Transaction{Host: "portquiz.net", Port: 8080, URL: "http://portquiz.net:8080/"})
+ sm.add(store.Transaction{Host: "portquiz.net", Port: 80, URL: "http://portquiz.net/"})
+
+ // Port 8080 displays as "portquiz.net:8080" — filter on port should work
+ sm.mu.Lock()
+ sm.textFilter = "8080"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 1, "filter on port number must match display host")
+
+ // Filter on hostname alone must match both ports
+ sm.mu.Lock()
+ sm.textFilter = "portquiz"
+ sm.mu.Unlock()
+ assert.Len(t, sm.childUIDs(""), 2, "filter on hostname must match all ports")
+}
+
+func TestSiteMap_Add_DeepPaths(t *testing.T) {
+ sm := newSiteMap()
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api/v1/users"})
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api/v1/posts"})
+ sm.add(store.Transaction{Host: "example.com", Port: 443, URL: "https://example.com/api/v2/users"})
+
+ hostUIDs := sm.childUIDs("")
+ assert.Len(t, hostUIDs, 1)
+
+ // /api
+ apiUIDs := sm.childUIDs(hostUIDs[0])
+ assert.Len(t, apiUIDs, 1, "should have one /api child")
+
+ // /api/v1 and /api/v2
+ versionUIDs := sm.childUIDs(apiUIDs[0])
+ assert.Len(t, versionUIDs, 2, "should have v1 and v2 children")
+}
diff --git a/internal/ui/inspector.go b/internal/ui/inspector.go
new file mode 100644
index 0000000..600cb17
--- /dev/null
+++ b/internal/ui/inspector.go
@@ -0,0 +1,125 @@
+package ui
+
+import (
+ "net/http"
+ "sort"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+func showInspectorDialog(tx store.Transaction, win fyne.Window) {
+ tabs := container.NewAppTabs()
+
+ if len(tx.ReqHeaders) > 0 {
+ tabs.Append(container.NewTabItem("Req Headers", buildHeadersList(tx.ReqHeaders)))
+ }
+ if len(tx.ReqBody) > 0 {
+ body := widgets.NewTextView()
+ body.SetWindow(win)
+ body.SetText(string(PrettyJSON(tx.ReqBody)))
+ tabs.Append(container.NewTabItem("Req Body", body.Build()))
+ }
+
+ tabs.Append(container.NewTabItem("Resp Headers", buildHeadersList(tx.RespHeaders)))
+
+ cookies := parseCookies(tx.RespHeaders)
+ tabs.Append(container.NewTabItem("Cookies", buildKVTable(cookies)))
+
+ if len(tx.RespBody) > 0 {
+ body := widgets.NewTextView()
+ body.SetWindow(win)
+ ct := tx.RespHeaders.Get("Content-Type")
+ if strings.Contains(ct, "application/json") {
+ body.SetText(string(PrettyJSON(tx.RespBody)))
+ tabs.Append(container.NewTabItem("JSON", body.Build()))
+ } else {
+ body.SetText(string(tx.RespBody))
+ tabs.Append(container.NewTabItem("Resp Body", body.Build()))
+ }
+ }
+
+ d := dialog.NewCustom("Inspector", "Close", tabs, win)
+ d.Resize(fyne.NewSize(700, 500))
+ closeOnEscape(win, d.Dismiss)
+ d.Show()
+}
+
+func parseCookies(headers http.Header) [][2]string {
+ var result [][2]string
+ for _, line := range headers["Set-Cookie"] {
+ parts := strings.SplitN(line, ";", 2)
+ cookieParts := strings.SplitN(strings.TrimSpace(parts[0]), "=", 2)
+ if len(cookieParts) == 2 {
+ result = append(result, [2]string{cookieParts[0], cookieParts[1]})
+ } else if len(cookieParts) == 1 {
+ result = append(result, [2]string{cookieParts[0], ""})
+ }
+ if len(parts) > 1 {
+ for attr := range strings.SplitSeq(parts[1], ";") {
+ attr = strings.TrimSpace(attr)
+ if attr == "" {
+ continue
+ }
+ attrParts := strings.SplitN(attr, "=", 2)
+ if len(attrParts) == 2 {
+ result = append(result, [2]string{" " + attrParts[0], attrParts[1]})
+ } else {
+ result = append(result, [2]string{" " + attrParts[0], ""})
+ }
+ }
+ }
+ }
+ return result
+}
+
+func buildHeadersList(headers http.Header) fyne.CanvasObject {
+ keys := make([]string, 0, len(headers))
+ for k := range headers {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var pairs [][2]string
+ for _, k := range keys {
+ for _, v := range headers[k] {
+ pairs = append(pairs, [2]string{k, v})
+ }
+ }
+ return buildKVTable(pairs)
+}
+
+func buildKVTable(pairs [][2]string) fyne.CanvasObject {
+ if len(pairs) == 0 {
+ l := widget.NewLabel("None")
+ l.Importance = widget.LowImportance
+ return container.NewCenter(l)
+ }
+ t := widget.NewTable(
+ func() (int, int) { return len(pairs), 2 },
+ func() fyne.CanvasObject {
+ l := widget.NewLabel("")
+ l.Truncation = fyne.TextTruncateEllipsis
+ return l
+ },
+ func(id widget.TableCellID, obj fyne.CanvasObject) {
+ l := obj.(*widget.Label)
+ if id.Col == 0 {
+ l.TextStyle = fyne.TextStyle{Bold: true}
+ l.SetText(pairs[id.Row][0])
+ } else {
+ l.TextStyle = fyne.TextStyle{}
+ l.SetText(pairs[id.Row][1])
+ }
+ },
+ )
+ t.SetColumnWidth(0, 200)
+ t.SetColumnWidth(1, 450)
+ return t
+}
diff --git a/internal/ui/intercept.go b/internal/ui/intercept.go
new file mode 100644
index 0000000..4ce8e74
--- /dev/null
+++ b/internal/ui/intercept.go
@@ -0,0 +1,216 @@
+package ui
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+)
+
+type interceptTab struct {
+ projectStore *store.Store
+ pending *store.PendingRequest
+
+ toggle *widget.Check
+ editor *widget.Entry
+ forward *widget.Button
+ drop *widget.Button
+ forwardAll *widget.Button
+}
+
+func newInterceptTab(projectStore *store.Store) *interceptTab {
+ return &interceptTab{projectStore: projectStore}
+}
+
+func (t *interceptTab) build() fyne.CanvasObject {
+ t.toggle = widget.NewCheck("Intercept ON", func(on bool) {
+ t.projectStore.Intercept.SetEnabled(on)
+ if !on {
+ // SetEnabled(false) triggers bypass internally — all goroutines
+ // blocked in Hold are released. We only need to handle whatever
+ // the UI is currently displaying.
+ if t.pending != nil {
+ old := t.pending
+ t.pending = nil
+ old.Reply <- store.Decision{Forward: true, Request: old.Request, Body: old.Body}
+ }
+ t.clearEditor()
+ }
+ })
+
+ t.editor = widget.NewMultiLineEntry()
+ t.editor.SetPlaceHolder("No request intercepted yet.")
+ t.editor.TextStyle = fyne.TextStyle{Monospace: true}
+ t.editor.Wrapping = fyne.TextWrapOff
+ t.editor.Disable()
+
+ t.forward = widget.NewButtonWithIcon("Forward", theme.ConfirmIcon(), func() {
+ t.decide(true)
+ })
+ t.drop = widget.NewButtonWithIcon("Drop", theme.DeleteIcon(), func() {
+ t.decide(false)
+ })
+ t.forwardAll = widget.NewButtonWithIcon("Forward All", theme.MediaFastForwardIcon(), func() {
+ // Forward whatever is currently displayed, then signal the gate to
+ // release all goroutines currently blocked in Hold. Future requests
+ // will be intercepted normally — ForwardAll is a one-shot release.
+ if t.pending != nil {
+ old := t.pending
+ t.pending = nil
+ old.Reply <- store.Decision{Forward: true, Request: old.Request, Body: old.Body}
+ }
+ t.clearEditor()
+ t.projectStore.Intercept.ForwardAll()
+ })
+
+ t.forward.Importance = widget.HighImportance
+ t.forward.Disable()
+ t.drop.Disable()
+ t.forwardAll.Disable()
+
+ scopeOnly := widget.NewCheck("Scope only", func(on bool) {
+ if on {
+ t.projectStore.Intercept.SetScopeFilter(t.projectStore.InScope)
+ } else {
+ t.projectStore.Intercept.SetScopeFilter(nil)
+ }
+ })
+
+ copyBtn := widget.NewButton("Copy", func() {
+ fyne.CurrentApp().Clipboard().SetContent(t.editor.Text)
+ })
+
+ buttons := container.NewHBox(t.forward, t.drop, t.forwardAll)
+
+ content := container.NewBorder(
+ container.NewVBox(
+ container.NewBorder(nil, nil,
+ container.NewHBox(t.toggle, scopeOnly),
+ copyBtn, nil),
+ widget.NewSeparator(),
+ ),
+ buttons,
+ nil, nil,
+ t.editor,
+ )
+
+ go t.watchQueue()
+
+ return content
+}
+
+// watchQueue reads from the gate queue and presents requests to the UI one at
+// a time. Cancelled pending (released by bypass before the UI processed them)
+// are skipped via the double IsDone check — once on the watchQueue goroutine
+// and once inside fyne.Do on the main goroutine.
+func (t *interceptTab) watchQueue() {
+ for pending := range t.projectStore.Intercept.Queue() {
+ p := pending
+ // Fast path: already cancelled before we even schedule fyne.Do.
+ if p.IsDone() {
+ continue
+ }
+ fyne.Do(func() {
+ // Second check: may have been cancelled between the fast path above
+ // and this callback executing on the main goroutine.
+ if p.IsDone() {
+ return
+ }
+ t.showRequest(p)
+ })
+ }
+}
+
+func formatLiveRequest(req *http.Request, body []byte) string {
+ var builder bytes.Buffer
+ path := req.URL.RequestURI()
+ if path == "" {
+ path = "/"
+ }
+ fmt.Fprintf(&builder, "%s %s HTTP/1.1\r\n", req.Method, path)
+ fmt.Fprintf(&builder, "Host: %s\r\n", req.Host)
+ for headerKey, headerValues := range req.Header {
+ for _, headerValue := range headerValues {
+ fmt.Fprintf(&builder, "%s: %s\r\n", headerKey, headerValue)
+ }
+ }
+ builder.WriteString("\r\n")
+ if len(body) > 0 {
+ builder.Write(body)
+ }
+ return builder.String()
+}
+
+func (t *interceptTab) showRequest(p *store.PendingRequest) {
+ t.pending = p
+ t.editor.Enable()
+ t.editor.SetText(formatLiveRequest(p.Request, p.Body))
+ t.forward.Enable()
+ t.drop.Enable()
+ t.forwardAll.Enable()
+}
+
+func (t *interceptTab) decide(forward bool) {
+ if t.pending == nil {
+ return
+ }
+ pending := t.pending
+ rawText := t.editor.Text
+ t.pending = nil
+ t.clearEditor()
+
+ if forward {
+ req, body, err := parseRawRequest(rawText, pending.Request)
+ if err != nil {
+ logger.Error("intercept: parse edited request: %v — forwarding original", err)
+ req = pending.Request
+ body = pending.Body
+ }
+ pending.Reply <- store.Decision{Forward: true, Request: req, Body: body}
+ } else {
+ pending.Reply <- store.Decision{Forward: false, Request: pending.Request, Body: pending.Body}
+ }
+}
+
+func (t *interceptTab) clearEditor() {
+ t.editor.SetText("")
+ t.editor.Disable()
+ t.forward.Disable()
+ t.drop.Disable()
+ t.forwardAll.Disable()
+}
+
+func parseRawRequest(raw string, original *http.Request) (*http.Request, []byte, error) {
+ req, err := http.ReadRequest(bufio.NewReader(bytes.NewBufferString(raw)))
+ if err != nil {
+ return nil, nil, err
+ }
+
+ req.URL.Scheme = original.URL.Scheme
+ req.URL.Host = original.URL.Host
+ if req.Host == "" {
+ req.Host = original.Host
+ }
+
+ var body []byte
+ if req.Body != nil {
+ var err error
+ body, err = io.ReadAll(req.Body)
+ req.Body.Close()
+ if err != nil {
+ return nil, nil, fmt.Errorf("read request body: %w", err)
+ }
+ }
+ req.Body = http.NoBody
+
+ return req, body, nil
+}
diff --git a/internal/ui/intruder.go b/internal/ui/intruder.go
new file mode 100644
index 0000000..7527b82
--- /dev/null
+++ b/internal/ui/intruder.go
@@ -0,0 +1,742 @@
+package ui
+
+import (
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+var intruderMarkerRegex = regexp.MustCompile(`\$<[^>]+>`)
+
+func applyPayloadEncoding(payload, encoding string) string {
+ switch encoding {
+ case "URL encode":
+ return url.QueryEscape(payload)
+ case "Double URL encode":
+ return url.QueryEscape(url.QueryEscape(payload))
+ case "Base64":
+ return base64.StdEncoding.EncodeToString([]byte(payload))
+ case "Hex":
+ return hex.EncodeToString([]byte(payload))
+ default:
+ return payload
+ }
+}
+
+type intruderResult struct {
+ payload string
+ statusCode int
+ size int
+ durationMs int64
+ err string
+ rawResp string
+ rawReq string
+}
+
+type intruderTab struct {
+ win fyne.Window
+ projectStore *store.Store
+ repeater *repeaterTab
+ loot *lootTab
+
+ reqEditor *widgets.TextViewEntry
+ payloadEntry *widget.Entry
+ filterEntry *widget.Entry
+ startBtn *widget.Button
+ stopBtn *widget.Button
+ progressLabel *widget.Label
+ responsePane *widgets.TextView
+ requestPane *widgets.TextView
+
+ sendToRepeaterBtn *widget.Button
+ sendToLootBtn *widget.Button
+ viewFullBtn *widget.Button
+ loadPayloadsBtn *widget.Button
+
+ payloadEncoding string // "none", "url", "double-url", "base64", "hex"
+
+ selectedResult *intruderResult
+
+ config store.IntruderConfig
+
+ mu sync.RWMutex
+ results []intruderResult
+ filtered []intruderResult
+
+ table *widgets.DataTable
+
+ running atomic.Bool
+ stopChan chan struct{}
+}
+
+func newIntruderTab(win fyne.Window, projectStore *store.Store, repeater *repeaterTab, loot *lootTab) *intruderTab {
+ return &intruderTab{
+ win: win,
+ projectStore: projectStore,
+ repeater: repeater,
+ loot: loot,
+ config: projectStore.LoadIntruderConfig(),
+ }
+}
+
+var intruderColumns = []widgets.DataTableColumn{
+ {Header: "Payload", Width: 200},
+ {Header: "Status", Width: 80},
+ {Header: "Size", Width: 100},
+ {Header: "Duration", Width: 100},
+}
+
+func (t *intruderTab) showConfigDialog() {
+ delayEntry := widget.NewEntry()
+ delayEntry.SetText(strconv.Itoa(t.config.DelayMs))
+ delayEntry.SetPlaceHolder("0")
+
+ stopOnStatusEntry := widget.NewEntry()
+ if t.config.StopOnStatus != 0 {
+ stopOnStatusEntry.SetText(strconv.Itoa(t.config.StopOnStatus))
+ }
+ stopOnStatusEntry.SetPlaceHolder("0 = disabled")
+
+ maxRedirectsEntry := widget.NewEntry()
+ maxRedirectsEntry.SetText(strconv.Itoa(t.config.MaxRedirects))
+ maxRedirectsEntry.SetPlaceHolder("10")
+
+ followRedirectsSelect := widget.NewSelect([]string{"never", "always", "in-scope"}, nil)
+ followRedirectsSelect.SetSelected(t.config.FollowRedirects)
+
+ timeoutEntry := widget.NewEntry()
+ timeoutEntry.SetText(strconv.Itoa(t.config.TimeoutMs))
+ timeoutEntry.SetPlaceHolder("30000")
+
+ encodingSelect := widget.NewSelect(
+ []string{"None", "URL encode", "Double URL encode", "Base64", "Hex"},
+ nil,
+ )
+ enc := t.payloadEncoding
+ if enc == "" {
+ enc = "None"
+ }
+ encodingSelect.SetSelected(enc)
+
+ form := widget.NewForm(
+ widget.NewFormItem("Delay between requests (ms)", delayEntry),
+ widget.NewFormItem("Stop on status code", stopOnStatusEntry),
+ widget.NewFormItem("Follow redirects", followRedirectsSelect),
+ widget.NewFormItem("Max redirects", maxRedirectsEntry),
+ widget.NewFormItem("Timeout (ms)", timeoutEntry),
+ widget.NewFormItem("Payload encoding", encodingSelect),
+ )
+
+ sized := container.NewGridWrap(fyne.NewSize(420, 310), form)
+ configDialog := dialog.NewCustomConfirm("Attack Configuration", "Save", "Cancel", sized, func(confirmed bool) {
+ if !confirmed {
+ return
+ }
+ if delay, err := strconv.Atoi(strings.TrimSpace(delayEntry.Text)); err == nil && delay >= 0 {
+ t.config.DelayMs = delay
+ }
+ if statusText := strings.TrimSpace(stopOnStatusEntry.Text); statusText == "" || statusText == "0" {
+ t.config.StopOnStatus = 0
+ } else if status, err := strconv.Atoi(statusText); err == nil && status > 0 {
+ t.config.StopOnStatus = status
+ }
+ if maxRedirects, err := strconv.Atoi(strings.TrimSpace(maxRedirectsEntry.Text)); err == nil && maxRedirects >= 0 {
+ t.config.MaxRedirects = maxRedirects
+ }
+ t.config.FollowRedirects = followRedirectsSelect.Selected
+ if timeout, err := strconv.Atoi(strings.TrimSpace(timeoutEntry.Text)); err == nil && timeout > 0 {
+ t.config.TimeoutMs = timeout
+ }
+ t.payloadEncoding = encodingSelect.Selected
+ t.config.RawRequest = t.reqEditor.GetText()
+ t.config.Payloads = t.payloadEntry.Text
+ t.projectStore.SaveIntruderConfig(t.config)
+ }, t.win)
+
+ closeOnEscape(t.win, configDialog.Dismiss)
+ configDialog.Show()
+}
+
+func (t *intruderTab) buildRequestPane() fyne.CanvasObject {
+ t.reqEditor = widgets.NewTextViewEntry()
+ t.reqEditor.SetWindow(t.win)
+ t.reqEditor.SetPlaceHolder("Paste raw HTTP request here, mark injection points with $<n>\n\nExample:\nGET /search?q=$<query> HTTP/1.1\nHost: example.com")
+ t.reqEditor.SetText(t.config.RawRequest)
+
+ t.payloadEntry = widget.NewMultiLineEntry()
+ t.payloadEntry.SetPlaceHolder("One payload per line:\nadmin\npassword\n' OR 1=1--")
+ t.payloadEntry.SetMinRowsVisible(8)
+ t.payloadEntry.SetText(t.config.Payloads)
+
+ t.loadPayloadsBtn = widget.NewButtonWithIcon("Load from file", theme.FolderOpenIcon(), func() {
+ fileDialog := dialog.NewFileOpen(func(readCloser fyne.URIReadCloser, err error) {
+ if err != nil || readCloser == nil {
+ return
+ }
+ defer readCloser.Close()
+ data, err := io.ReadAll(readCloser)
+ if err != nil {
+ return
+ }
+ t.payloadEntry.SetText(string(data))
+ }, t.win)
+ fileDialog.SetFilter(storage.NewExtensionFileFilter([]string{".txt"}))
+ fileDialog.Show()
+ })
+
+ payloadPane := container.NewBorder(
+ paneHeader("Payloads"),
+ nil, nil, nil,
+ t.payloadEntry,
+ )
+
+ configSplit := container.NewHSplit(
+ container.NewBorder(paneHeader("Request"), nil, nil, nil, t.reqEditor.Build()),
+ payloadPane,
+ )
+ configSplit.SetOffset(0.6)
+ return configSplit
+}
+
+func (t *intruderTab) tableCellValue(row, col int) string {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ if row >= len(t.filtered) {
+ return ""
+ }
+ result := t.filtered[row]
+ switch col {
+ case 0:
+ return result.payload
+ case 1:
+ if result.err != "" {
+ return "ERR"
+ }
+ return fmt.Sprintf("%d", result.statusCode)
+ case 2:
+ if result.err != "" {
+ return "-"
+ }
+ return fmt.Sprintf("%db", result.size)
+ case 3:
+ if result.err != "" {
+ return "-"
+ }
+ return fmt.Sprintf("%dms", result.durationMs)
+ }
+ return ""
+}
+
+func (t *intruderTab) tableCellStyle(row, col int) widget.Importance {
+ if col != 1 {
+ return widget.MediumImportance
+ }
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ if row >= len(t.filtered) {
+ return widget.MediumImportance
+ }
+ result := t.filtered[row]
+ if result.err != "" {
+ return widget.DangerImportance
+ }
+ switch {
+ case result.statusCode >= 500:
+ return widget.DangerImportance
+ case result.statusCode >= 400:
+ return widget.WarningImportance
+ case result.statusCode >= 200 && result.statusCode < 300:
+ return widget.SuccessImportance
+ }
+ return widget.MediumImportance
+}
+
+func (t *intruderTab) tableOnSelect(row int) {
+ t.mu.RLock()
+ if row >= len(t.filtered) {
+ t.mu.RUnlock()
+ return
+ }
+ result := t.filtered[row]
+ t.mu.RUnlock()
+ t.selectedResult = &result
+ t.responsePane.SetText(result.rawResp)
+ t.requestPane.SetText(result.rawReq)
+ t.sendToRepeaterBtn.Enable()
+ t.sendToLootBtn.Enable()
+ if len(result.rawResp) > internalhttp.MaxDisplayBytes {
+ t.viewFullBtn.Show()
+ } else {
+ t.viewFullBtn.Hide()
+ }
+}
+
+func (t *intruderTab) buildResultsTable() fyne.CanvasObject {
+ t.filterEntry = widget.NewEntry()
+ t.filterEntry.SetPlaceHolder("Filter results — payload, status...")
+ t.filterEntry.OnChanged = func(_ string) { t.applyFilter() }
+
+ t.table = widgets.NewDataTable()
+ t.table.SetWindow(t.win)
+ t.table.Columns = intruderColumns
+ t.table.RowCount = func() int {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return len(t.filtered)
+ }
+ t.table.CellValue = t.tableCellValue
+ t.table.CellStyle = t.tableCellStyle
+ t.table.RowID = func(row int) int64 { return int64(row) }
+ t.table.OnSelect = t.tableOnSelect
+ t.table.MenuItems = func(row int) []widgets.ContextMenuItem {
+ t.mu.RLock()
+ if row >= len(t.filtered) {
+ t.mu.RUnlock()
+ return nil
+ }
+ result := t.filtered[row]
+ t.mu.RUnlock()
+ return t.contextMenuItems(result)
+ }
+
+ return container.NewBorder(
+ container.NewBorder(nil, nil, newBoldLabel("Results"), nil, t.filterEntry),
+ nil, nil, nil,
+ t.table.Build(),
+ )
+}
+
+func (t *intruderTab) buildResultDetailPane() fyne.CanvasObject {
+ t.responsePane = widgets.NewTextView()
+ t.responsePane.SetWindow(t.win)
+ t.responsePane.SetPlaceHolder("Select a result to view response...")
+
+ t.requestPane = widgets.NewTextView()
+ t.requestPane.SetWindow(t.win)
+ t.requestPane.SetPlaceHolder("Select a result to view request...")
+
+ copyReqBtn := widget.NewButton("Copy", func() {
+ if t.selectedResult != nil {
+ fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawReq)
+ }
+ })
+ copyRespBtn := widget.NewButton("Copy", func() {
+ if t.selectedResult != nil {
+ fyne.CurrentApp().Clipboard().SetContent(t.selectedResult.rawResp)
+ }
+ })
+ t.viewFullBtn = widget.NewButton("View Full", func() {
+ if t.selectedResult != nil {
+ showFullBodyDialog([]byte(t.selectedResult.rawResp), t.win)
+ }
+ })
+ t.viewFullBtn.Hide()
+
+ detailPane := container.NewHSplit(
+ container.NewBorder(
+ paneHeader("Request", copyReqBtn),
+ nil, nil, nil, t.requestPane.Build(),
+ ),
+ container.NewBorder(
+ paneHeader("Response", t.viewFullBtn, copyRespBtn),
+ nil, nil, nil, t.responsePane.Build(),
+ ),
+ )
+ return detailPane
+}
+
+func (t *intruderTab) clearResults() {
+ t.mu.Lock()
+ t.results = nil
+ t.filtered = nil
+ t.mu.Unlock()
+ t.table.Refresh()
+ t.progressLabel.SetText("")
+ t.responsePane.SetText("")
+ t.requestPane.SetText("")
+ t.selectedResult = nil
+ t.sendToRepeaterBtn.Disable()
+ t.sendToLootBtn.Disable()
+}
+
+func (t *intruderTab) sendSelectedToRepeater() {
+ if t.selectedResult == nil {
+ return
+ }
+ host, port, useTLS := internalhttp.ParseHostFromRaw(t.selectedResult.rawReq)
+ path := PathOf(internalhttp.ExtractURL(t.selectedResult.rawReq))
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, t.selectedResult.rawReq)
+}
+
+func (t *intruderTab) buildToolbar() fyne.CanvasObject {
+ t.progressLabel = widget.NewLabel("")
+ t.progressLabel.Importance = widget.LowImportance
+
+ t.startBtn = widget.NewButtonWithIcon("Start Attack", theme.MediaPlayIcon(), func() { t.startAttack() })
+ t.startBtn.Importance = widget.HighImportance
+ t.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), func() { t.stopAttack() })
+ t.stopBtn.Disable()
+
+ configBtn := widget.NewButtonWithIcon("Config", AppIcon("settings"), func() { t.showConfigDialog() })
+
+ t.sendToRepeaterBtn = widget.NewButtonWithIcon("Repeater", theme.MailForwardIcon(), func() { t.sendSelectedToRepeater() })
+ t.sendToRepeaterBtn.Disable()
+ t.sendToLootBtn = widget.NewButtonWithIcon("Loot", AppIcon("loot"), func() {
+ if t.selectedResult != nil {
+ t.loot.showAddDialog(nil, t.selectedResult.rawReq, t.selectedResult.rawResp)
+ }
+ })
+ t.sendToLootBtn.Disable()
+
+ clearBtn := widget.NewButtonWithIcon("Clear Results", AppIcon("delete"), func() { t.clearResults() })
+
+ return container.NewHBox(t.startBtn, t.stopBtn, configBtn, clearBtn, t.progressLabel, layout.NewSpacer(), t.loadPayloadsBtn)
+}
+
+func (t *intruderTab) build() fyne.CanvasObject {
+ requestPane := t.buildRequestPane()
+ detailPane := t.buildResultDetailPane()
+ tablePane := t.buildResultsTable()
+ toolbar := t.buildToolbar()
+
+ resultsSplit := container.NewHSplit(tablePane, detailPane)
+ resultsSplit.SetOffset(0.4)
+
+ mainSplit := container.NewVSplit(requestPane, resultsSplit)
+ mainSplit.SetOffset(0.45)
+
+ return container.NewBorder(toolbar, nil, nil, nil, mainSplit)
+}
+
+func (t *intruderTab) followRedirect(rawResp string, originalHost string) (string, bool) {
+ firstLine := strings.SplitN(rawResp, "\r\n", 2)[0]
+ parts := strings.Fields(firstLine)
+ if len(parts) < 2 {
+ return "", false
+ }
+
+ var statusCode int
+ fmt.Sscanf(parts[1], "%d", &statusCode)
+ if statusCode < 300 || statusCode >= 400 {
+ return "", false
+ }
+
+ location := ""
+ for _, line := range strings.Split(rawResp, "\r\n") {
+ if strings.HasPrefix(strings.ToLower(line), "location:") {
+ location = strings.TrimSpace(line[9:])
+ break
+ }
+ }
+ if location == "" {
+ return "", false
+ }
+
+ switch t.config.FollowRedirects {
+ case "never":
+ return "", false
+ case "in-scope":
+ // location may be an absolute URL — extract the hostname before the scope check.
+ scopeHost := location
+ if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
+ rest := location[strings.Index(location, "//")+2:]
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ scopeHost = rest[:slash]
+ } else {
+ scopeHost = rest
+ }
+ }
+ if !t.projectStore.InScope(scopeHost) {
+ return "", false
+ }
+ }
+
+ var redirectHost, redirectPath string
+ var useTLS bool
+
+ if strings.HasPrefix(location, "https://") {
+ rest := location[8:]
+ useTLS = true
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ redirectHost = rest[:slash]
+ redirectPath = rest[slash:]
+ } else {
+ redirectHost = rest
+ redirectPath = "/"
+ }
+ } else if strings.HasPrefix(location, "http://") {
+ rest := location[7:]
+ useTLS = false
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ redirectHost = rest[:slash]
+ redirectPath = rest[slash:]
+ } else {
+ redirectHost = rest
+ redirectPath = "/"
+ }
+ } else {
+ redirectHost = originalHost
+ redirectPath = location
+ useTLS = strings.HasSuffix(originalHost, ":443")
+ }
+
+ port := 443
+ if !useTLS {
+ port = 80
+ }
+ if host, portStr, err := net.SplitHostPort(redirectHost); err == nil {
+ redirectHost = host
+ fmt.Sscanf(portStr, "%d", &port)
+ }
+
+ redirectReq := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\n\r\n", redirectPath, redirectHost)
+ result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: redirectHost,
+ Port: port,
+ TLS: useTLS,
+ RawReq: redirectReq,
+ TimeoutMs: t.config.TimeoutMs,
+ })
+ if err != nil {
+ return "", false
+ }
+ return result.Raw, true
+}
+
+func (t *intruderTab) startAttack() {
+ rawReq := t.reqEditor.GetText()
+ if rawReq == "" {
+ dialog.ShowInformation("Error", "Please enter a request.", t.win)
+ return
+ }
+
+ markers := intruderMarkerRegex.FindAllString(rawReq, -1)
+ if len(markers) == 0 {
+ dialog.ShowInformation("Error", "No injection points found. Mark them with $<n>.", t.win)
+ return
+ }
+
+ payloadsText := strings.TrimSpace(t.payloadEntry.Text)
+ if payloadsText == "" {
+ dialog.ShowInformation("Error", "Please enter at least one payload.", t.win)
+ return
+ }
+
+ payloads := strings.Split(payloadsText, "\n")
+ var cleanPayloads []string
+ for _, payload := range payloads {
+ payload = strings.TrimSpace(payload)
+ if payload != "" {
+ cleanPayloads = append(cleanPayloads, payload)
+ }
+ }
+
+ if len(cleanPayloads) == 0 {
+ dialog.ShowInformation("Error", "No valid payloads found.", t.win)
+ return
+ }
+
+ t.config.RawRequest = rawReq
+ t.config.Payloads = t.payloadEntry.Text
+ t.projectStore.SaveIntruderConfig(t.config)
+
+ t.mu.Lock()
+ t.results = nil
+ t.filtered = nil
+ t.mu.Unlock()
+ t.table.ClearSelection()
+ t.table.Refresh()
+ t.responsePane.SetText("")
+ t.requestPane.SetText("")
+ t.selectedResult = nil
+ t.sendToRepeaterBtn.Disable()
+ t.sendToLootBtn.Disable()
+
+ t.running.Store(true)
+ t.stopChan = make(chan struct{})
+ t.startBtn.Disable()
+ t.stopBtn.Enable()
+
+ config := t.config
+ encoding := t.payloadEncoding
+ total := len(cleanPayloads) * len(markers)
+ done := 0
+
+ go func() {
+ for _, marker := range markers {
+ for _, payload := range cleanPayloads {
+ select {
+ case <-t.stopChan:
+ fyne.Do(func() {
+ t.progressLabel.SetText(fmt.Sprintf("Stopped after %d/%d requests", done, total))
+ t.startBtn.Enable()
+ t.stopBtn.Disable()
+ })
+ return
+ default:
+ }
+
+ if config.DelayMs > 0 {
+ time.Sleep(time.Duration(config.DelayMs) * time.Millisecond)
+ }
+
+ encodedPayload := applyPayloadEncoding(payload, encoding)
+ injected := strings.ReplaceAll(rawReq, marker, encodedPayload)
+ host, port, useTLS := internalhttp.ParseHostFromRaw(injected)
+
+ var result intruderResult
+ result.payload = payload
+ result.rawReq = injected
+
+ if host == "" {
+ result.err = "no Host header found"
+ } else {
+ start := time.Now()
+ resp, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host,
+ Port: port,
+ TLS: useTLS,
+ RawReq: injected,
+ TimeoutMs: config.TimeoutMs,
+ })
+ elapsed := time.Since(start).Milliseconds()
+ if err != nil {
+ result.err = err.Error()
+ } else {
+ rawResp := resp.Raw
+ if config.FollowRedirects != "never" && config.MaxRedirects > 0 {
+ for redirectCount := 0; redirectCount < config.MaxRedirects; redirectCount++ {
+ redirectResp, followed := t.followRedirect(rawResp, host)
+ if !followed {
+ break
+ }
+ rawResp = redirectResp
+ }
+ }
+ result.durationMs = elapsed
+ result.size = len(rawResp)
+ result.rawResp = rawResp
+ firstLine := strings.SplitN(rawResp, "\r\n", 2)[0]
+ parts := strings.Fields(firstLine)
+ if len(parts) >= 2 {
+ fmt.Sscanf(parts[1], "%d", &result.statusCode)
+ }
+ }
+ }
+
+ done++
+ shouldStop := config.StopOnStatus != 0 && result.statusCode == config.StopOnStatus
+ resultCopy := result
+
+ fyne.Do(func() {
+ t.mu.Lock()
+ t.results = append(t.results, resultCopy)
+ t.mu.Unlock()
+ t.applyFilter()
+ t.progressLabel.SetText(fmt.Sprintf("%d/%d", done, total))
+ })
+
+ if shouldStop {
+ fyne.Do(func() {
+ t.progressLabel.SetText(fmt.Sprintf("Stopped — status %d matched after %d requests", config.StopOnStatus, done))
+ t.startBtn.Enable()
+ t.stopBtn.Disable()
+ })
+ return
+ }
+ }
+ }
+
+ fyne.Do(func() {
+ t.running.Store(false)
+ t.startBtn.Enable()
+ t.stopBtn.Disable()
+ t.progressLabel.SetText(fmt.Sprintf("Done — %d requests", done))
+ })
+ }()
+}
+
+func (t *intruderTab) stopAttack() {
+ if t.stopChan != nil {
+ close(t.stopChan)
+ }
+ t.running.Store(false)
+}
+
+func (t *intruderTab) applyFilter() {
+ query := strings.ToLower(t.filterEntry.Text)
+
+ t.mu.Lock()
+ var filtered []intruderResult
+ for _, result := range t.results {
+ if query != "" {
+ searchable := strings.ToLower(fmt.Sprintf("%s %d %s", result.payload, result.statusCode, result.err))
+ if !strings.Contains(searchable, query) {
+ continue
+ }
+ }
+ filtered = append(filtered, result)
+ }
+ t.filtered = filtered
+ t.mu.Unlock()
+
+ t.table.Refresh()
+}
+
+func (t *intruderTab) contextMenuItems(result intruderResult) []widgets.ContextMenuItem {
+ return []widgets.ContextMenuItem{
+ {
+ Label: "Send to Repeater",
+ Action: func() {
+ host, port, useTLS := internalhttp.ParseHostFromRaw(result.rawReq)
+ path := PathOf(internalhttp.ExtractURL(result.rawReq))
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ t.repeater.AddTab(fmt.Sprintf("Intruder %s", path), host, port, useTLS, result.rawReq)
+ },
+ },
+ {
+ Label: "Send to Loot",
+ Action: func() {
+ t.loot.showAddDialog(nil, result.rawReq, result.rawResp)
+ },
+ },
+ {
+ Label: "Copy Request",
+ Action: func() {
+ fyne.CurrentApp().Clipboard().SetContent(result.rawReq)
+ },
+ },
+ {
+ Label: "Copy Response",
+ Action: func() {
+ fyne.CurrentApp().Clipboard().SetContent(result.rawResp)
+ },
+ },
+ }
+}
diff --git a/internal/ui/keybinds.go b/internal/ui/keybinds.go
new file mode 100644
index 0000000..b493bf7
--- /dev/null
+++ b/internal/ui/keybinds.go
@@ -0,0 +1,92 @@
+package ui
+
+import (
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/driver/desktop"
+)
+
+const (
+ prefKeySendRequest = "shortcut_send_request"
+ prefKeyCloseTab = "shortcut_close_tab"
+ prefKeyToRepeater = "shortcut_to_repeater"
+
+ defaultKeySendRequest fyne.KeyName = fyne.KeyS
+ defaultKeyCloseTab fyne.KeyName = fyne.KeyW
+ defaultKeyToRepeater fyne.KeyName = fyne.KeyR
+)
+
+type Keybinds struct {
+ win fyne.Window
+ tabs *container.AppTabs
+ history *historyTab
+ repeater *repeaterTab
+ active []fyne.Shortcut
+
+ KeySendRequest fyne.KeyName
+ KeyCloseTab fyne.KeyName
+ KeyToRepeater fyne.KeyName
+}
+
+func newKeybinds(win fyne.Window, tabs *container.AppTabs, history *historyTab, repeater *repeaterTab, prefs fyne.Preferences) *Keybinds {
+ keybinds := &Keybinds{
+ win: win,
+ tabs: tabs,
+ history: history,
+ repeater: repeater,
+ KeySendRequest: fyne.KeyName(prefs.StringWithFallback(prefKeySendRequest, string(defaultKeySendRequest))),
+ KeyCloseTab: fyne.KeyName(prefs.StringWithFallback(prefKeyCloseTab, string(defaultKeyCloseTab))),
+ KeyToRepeater: fyne.KeyName(prefs.StringWithFallback(prefKeyToRepeater, string(defaultKeyToRepeater))),
+ }
+ keybinds.Update()
+ return keybinds
+}
+
+func (keybinds *Keybinds) Update() {
+ for _, registeredShortcut := range keybinds.active {
+ keybinds.win.Canvas().RemoveShortcut(registeredShortcut)
+ }
+ keybinds.active = nil
+
+ isActiveTab := func(tabName string) bool {
+ selectedTab := keybinds.tabs.Selected()
+ return selectedTab != nil && selectedTab.Text == tabName
+ }
+
+ sendRequestShortcut := &desktop.CustomShortcut{KeyName: keybinds.KeySendRequest, Modifier: fyne.KeyModifierControl}
+ keybinds.win.Canvas().AddShortcut(sendRequestShortcut, func(_ fyne.Shortcut) {
+ if !isActiveTab("Repeater") {
+ return
+ }
+ selectedTab := keybinds.repeater.tabs.Selected()
+ if selectedTab == nil {
+ return
+ }
+ if sendFn, ok := keybinds.repeater.sendFns[selectedTab]; ok {
+ sendFn()
+ }
+ })
+ keybinds.active = append(keybinds.active, sendRequestShortcut)
+
+ closeTabShortcut := &desktop.CustomShortcut{KeyName: keybinds.KeyCloseTab, Modifier: fyne.KeyModifierControl}
+ keybinds.win.Canvas().AddShortcut(closeTabShortcut, func(_ fyne.Shortcut) {
+ if !isActiveTab("Repeater") {
+ return
+ }
+ selectedTab := keybinds.repeater.tabs.Selected()
+ if selectedTab == nil {
+ return
+ }
+ keybinds.repeater.closeTab(selectedTab)
+ keybinds.repeater.tabs.Remove(selectedTab)
+ })
+ keybinds.active = append(keybinds.active, closeTabShortcut)
+
+ toRepeaterShortcut := &desktop.CustomShortcut{KeyName: keybinds.KeyToRepeater, Modifier: fyne.KeyModifierControl}
+ keybinds.win.Canvas().AddShortcut(toRepeaterShortcut, func(_ fyne.Shortcut) {
+ if isActiveTab("History") && keybinds.history.hasSelected {
+ keybinds.history.sendToRepeater(keybinds.history.selectedTx)
+ }
+ })
+ keybinds.active = append(keybinds.active, toRepeaterShortcut)
+}
diff --git a/internal/ui/launch.go b/internal/ui/launch.go
new file mode 100644
index 0000000..75dca54
--- /dev/null
+++ b/internal/ui/launch.go
@@ -0,0 +1,219 @@
+package ui
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/assets"
+)
+
+const maxRecentProjects = 10
+
+type RecentProject struct {
+ Path string `json:"path"`
+ LastOpen time.Time `json:"last_open"`
+}
+
+func ShowLaunchScreen(app fyne.App, onSelect func(projectPath string, w fyne.Window)) {
+ launchWin := app.NewWindow("Shiv")
+ applyTheme(app)
+ launchWin.Resize(fyne.NewSize(560, 420))
+ launchWin.CenterOnScreen()
+ launchWin.SetFixedSize(true)
+
+ var list *widget.List
+ var onDeleteBtn func(widget.ListItemID)
+
+ recents, _ := loadRecents()
+
+ list = widget.NewList(
+ func() int { return len(recents) },
+ func() fyne.CanvasObject {
+ return container.NewHBox(
+ widget.NewIcon(AppIcon("project")),
+ container.NewHBox(
+ widget.NewLabel("name"),
+ widget.NewLabel("date"),
+ ),
+ layout.NewSpacer(),
+ widget.NewButtonWithIcon("", AppIcon("delete"), nil),
+ )
+ },
+ func(i widget.ListItemID, obj fyne.CanvasObject) {
+ hbox := obj.(*fyne.Container)
+ box := hbox.Objects[1].(*fyne.Container)
+ box.Objects[0].(*widget.Label).SetText(filepath.Base(recents[i].Path))
+ box.Objects[1].(*widget.Label).SetText(recents[i].LastOpen.Format("2006-01-02 15:04"))
+ box.Objects[1].(*widget.Label).Importance = widget.LowImportance
+ hbox.Objects[3].(*widget.Button).OnTapped = func() { onDeleteBtn(i) }
+ },
+ )
+
+ onDeleteBtn = func(i widget.ListItemID) {
+ deleteRecent(recents[i].Path)
+ recents, _ = loadRecents()
+ list.Refresh()
+ }
+
+ list.OnSelected = func(i widget.ListItemID) {
+ path := recents[i].Path
+ if _, err := os.Stat(path); err != nil {
+ dialog.ShowError(err, launchWin)
+ list.UnselectAll()
+ return
+ }
+ saveRecent(path)
+ onSelect(path, launchWin)
+ }
+
+ newBtn := widget.NewButtonWithIcon("New Project", theme.DocumentCreateIcon(), func() {
+ saveDialog := dialog.NewFileSave(func(writeCloser fyne.URIWriteCloser, err error) {
+ if err != nil || writeCloser == nil {
+ return
+ }
+ writeCloser.Close()
+ path := writeCloser.URI().Path()
+ saveRecent(path)
+ onSelect(path, launchWin)
+ }, launchWin)
+ saveDialog.SetFileName("untitled.shiv")
+ saveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".shiv"}))
+ saveDialog.Show()
+ })
+
+ openBtn := widget.NewButtonWithIcon("Open Project", theme.FolderOpenIcon(), func() {
+ openDialog := dialog.NewFileOpen(func(readCloser fyne.URIReadCloser, err error) {
+ if err != nil || readCloser == nil {
+ return
+ }
+ readCloser.Close()
+ path := readCloser.URI().Path()
+ saveRecent(path)
+ onSelect(path, launchWin)
+ }, launchWin)
+ openDialog.SetFilter(storage.NewExtensionFileFilter([]string{".shiv"}))
+ openDialog.Show()
+ })
+ newBtn.Importance = widget.HighImportance
+
+ logo := canvas.NewImageFromResource(fyne.NewStaticResource("logo.png", assets.Logo))
+ logo.FillMode = canvas.ImageFillContain
+ logo.SetMinSize(fyne.NewSize(128, 128))
+
+ subtitle := widget.NewLabel("HTTP/HTTPS Proxy Ninja")
+ subtitle.Importance = widget.LowImportance
+
+ recentLabel := widget.NewLabel("Recent Projects")
+ recentLabel.TextStyle = fyne.TextStyle{Bold: true}
+
+ header := container.NewVBox(
+ container.NewCenter(logo),
+ container.NewCenter(subtitle),
+ widget.NewSeparator(),
+ )
+
+ buttons := container.NewGridWithColumns(2, newBtn, openBtn)
+
+ var content fyne.CanvasObject
+ if len(recents) == 0 {
+ empty := widget.NewLabel("No recent projects")
+ empty.Importance = widget.LowImportance
+ content = container.NewBorder(
+ container.NewVBox(header, recentLabel),
+ buttons,
+ nil, nil,
+ container.NewCenter(empty),
+ )
+ } else {
+ content = container.NewBorder(
+ container.NewVBox(header, recentLabel),
+ buttons,
+ nil, nil,
+ list,
+ )
+ }
+
+ launchWin.SetContent(container.NewPadded(content))
+ launchWin.Show()
+}
+
+func recentsPath() (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(configDir, "shiv", "recent_projects.json"), nil
+}
+
+func loadRecents() ([]RecentProject, error) {
+ path, err := recentsPath()
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var recents []RecentProject
+ if err := json.Unmarshal(data, &recents); err != nil {
+ return nil, err
+ }
+ return recents, nil
+}
+
+func saveRecent(projectPath string) {
+ recents, _ := loadRecents()
+
+ filtered := recents[:0]
+ for _, recent := range recents {
+ if recent.Path != projectPath {
+ filtered = append(filtered, recent)
+ }
+ }
+
+ updated := append([]RecentProject{{Path: projectPath, LastOpen: time.Now()}}, filtered...)
+ if len(updated) > maxRecentProjects {
+ updated = updated[:maxRecentProjects]
+ }
+
+ path, err := recentsPath()
+ if err != nil {
+ return
+ }
+ data, err := json.Marshal(updated)
+ if err != nil {
+ return
+ }
+ os.WriteFile(path, data, 0o644)
+}
+
+func deleteRecent(projectPath string) {
+ os.Remove(projectPath)
+ recents, _ := loadRecents()
+ filtered := recents[:0]
+ for _, r := range recents {
+ if r.Path != projectPath {
+ filtered = append(filtered, r)
+ }
+ }
+ path, err := recentsPath()
+ if err != nil {
+ return
+ }
+ data, err := json.Marshal(filtered)
+ if err != nil {
+ return
+ }
+ os.WriteFile(path, data, 0o644)
+}
diff --git a/internal/ui/loot.go b/internal/ui/loot.go
new file mode 100644
index 0000000..df98ee0
--- /dev/null
+++ b/internal/ui/loot.go
@@ -0,0 +1,394 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "text/template"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/assets"
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+var severities = []string{"Critical", "High", "Medium", "Low", "Info"}
+
+var severityImportance = map[string]widget.Importance{
+ "Critical": widget.DangerImportance,
+ "High": widget.DangerImportance,
+ "Medium": widget.WarningImportance,
+ "Low": widget.MediumImportance,
+ "Info": widget.LowImportance,
+}
+
+type lootTab struct {
+ projectStore *store.Store
+ win fyne.Window
+ repeater *repeaterTab
+ entries []store.LootEntry
+
+ table *widgets.DataTable
+ notesArea *widgets.TextView
+ viewReqBtn *widget.Button
+ deleteBtn *widget.Button
+ exportBtn *widget.Button
+
+ selectedIdx int
+}
+
+var lootColumns = []widgets.DataTableColumn{
+ {Header: "Severity", Width: 300},
+ {Header: "Title", Width: 500},
+ {Header: "Created", Width: 360},
+}
+
+func newLootTab(projectStore *store.Store, win fyne.Window, repeater *repeaterTab) *lootTab {
+ return &lootTab{
+ projectStore: projectStore,
+ win: win,
+ repeater: repeater,
+ selectedIdx: -1,
+ }
+}
+
+func (l *lootTab) build() fyne.CanvasObject {
+ l.table = widgets.NewDataTable()
+ l.table.SetWindow(l.win)
+ l.table.Columns = lootColumns
+ l.table.RowCount = func() int { return len(l.entries) }
+ l.table.CellValue = func(row, col int) string {
+ if row >= len(l.entries) {
+ return ""
+ }
+ entry := l.entries[row]
+ switch col {
+ case 0:
+ return entry.Severity
+ case 1:
+ return entry.Title
+ case 2:
+ return entry.CreatedAt.Format("2006-01-02 15:04")
+ }
+ return ""
+ }
+ l.table.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(l.entries) {
+ return widget.MediumImportance
+ }
+ if imp, ok := severityImportance[l.entries[row].Severity]; ok {
+ return imp
+ }
+ return widget.MediumImportance
+ }
+ l.table.RowID = func(row int) int64 {
+ if row >= len(l.entries) {
+ return 0
+ }
+ return int64(l.entries[row].ID)
+ }
+ l.table.OnSelect = func(row int) {
+ if row >= len(l.entries) {
+ return
+ }
+ l.selectedIdx = row
+ l.deleteBtn.Enable()
+ entry := l.entries[row]
+ l.notesArea.SetText(fmt.Sprintf("Severity: %s\nCreated: %s\n\n%s",
+ entry.Severity, entry.CreatedAt.Format("2006-01-02 15:04"), entry.Notes))
+ if entry.HistoryID != nil || entry.RawRequest != "" {
+ l.viewReqBtn.Enable()
+ } else {
+ l.viewReqBtn.Disable()
+ }
+ }
+ l.table.MenuItems = func(row int) []widgets.ContextMenuItem {
+ if row >= len(l.entries) {
+ return nil
+ }
+ entry := l.entries[row]
+ return []widgets.ContextMenuItem{
+ {
+ Label: "Delete",
+ Action: func() {
+ dialog.ShowConfirm("Delete", fmt.Sprintf("Delete '%s'?", entry.Title), func(ok bool) {
+ if !ok {
+ return
+ }
+ if err := l.projectStore.DeleteLoot(entry.ID); err != nil {
+ logger.Error("loot: delete: %v", err)
+ return
+ }
+ l.selectedIdx = -1
+ l.deleteBtn.Disable()
+ l.notesArea.SetText("")
+ }, l.win)
+ },
+ },
+ {
+ Label: "View Request",
+ Action: func() {
+ if entry.HistoryID != nil || entry.RawRequest != "" {
+ l.showLinkedRequest(entry)
+ }
+ },
+ },
+ }
+ }
+
+ tableObj := l.table.Build()
+
+ l.notesArea = widgets.NewTextView()
+ l.notesArea.SetWindow(l.win)
+ l.notesArea.SetPlaceHolder("Select an entry to view details...")
+
+ l.deleteBtn = widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.entries) {
+ return
+ }
+ entry := l.entries[l.selectedIdx]
+ dialog.ShowConfirm("Delete", fmt.Sprintf("Delete '%s'?", entry.Title), func(ok bool) {
+ if !ok {
+ return
+ }
+ if err := l.projectStore.DeleteLoot(entry.ID); err != nil {
+ logger.Error("loot: delete: %v", err)
+ return
+ }
+ l.selectedIdx = -1
+ l.deleteBtn.Disable()
+ l.notesArea.SetText("")
+ }, l.win)
+ })
+ l.deleteBtn.Disable()
+
+ addBtn := widget.NewButtonWithIcon("Add", theme.ContentAddIcon(), func() {
+ l.showAddDialog(nil, "", "")
+ })
+ addBtn.Importance = widget.HighImportance
+
+ l.exportBtn = widget.NewButtonWithIcon("Export Markdown", theme.DocumentSaveIcon(), func() {
+ l.exportMarkdown()
+ })
+
+ l.viewReqBtn = widget.NewButtonWithIcon("View Request", theme.SearchIcon(), func() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.entries) {
+ return
+ }
+ l.showLinkedRequest(l.entries[l.selectedIdx])
+ })
+ l.viewReqBtn.Disable()
+
+ split := container.NewVSplit(
+ container.NewBorder(container.NewHBox(addBtn, l.deleteBtn, l.viewReqBtn, l.exportBtn), nil, nil, nil, tableObj),
+ container.NewBorder(paneHeader("Notes"), nil, nil, nil, l.notesArea.Build()),
+ )
+ split.SetOffset(0.6)
+
+ l.reload()
+
+ // Watch for new loot entries added by plugins or other goroutines.
+ go func() {
+ for range l.projectStore.LootEntries {
+ fyne.Do(func() {
+ l.reload()
+ })
+ }
+ }()
+
+ return split
+}
+
+func (l *lootTab) reload() {
+ entries, err := l.projectStore.AllLoot()
+ if err != nil {
+ logger.Error("loot: load: %v", err)
+ return
+ }
+ l.entries = entries
+ l.table.Refresh()
+}
+
+func (l *lootTab) showAddDialog(historyID *uint64, rawRequest string, rawResponse string) {
+ titleEntry := widget.NewEntry()
+ titleEntry.SetPlaceHolder("e.g. Admin credentials found")
+
+ severitySelect := widget.NewSelect(severities, nil)
+ severitySelect.SetSelected("High")
+
+ notesEntry := widget.NewMultiLineEntry()
+ notesEntry.SetPlaceHolder("Describe the finding, include evidence, impact...")
+ notesEntry.SetMinRowsVisible(6)
+
+ form := widget.NewForm(
+ widget.NewFormItem("Title", titleEntry),
+ widget.NewFormItem("Severity", severitySelect),
+ widget.NewFormItem("Notes", notesEntry),
+ )
+
+ d := dialog.NewCustomConfirm("Add Loot", "Save", "Cancel",
+ container.NewGridWrap(fyne.NewSize(500, 350), form),
+ func(confirmed bool) {
+ if !confirmed {
+ return
+ }
+ title := strings.TrimSpace(titleEntry.Text)
+ if title == "" {
+ return
+ }
+ if _, err := l.projectStore.AddLoot(store.LootEntry{
+ Title: title,
+ Severity: severitySelect.Selected,
+ Notes: notesEntry.Text,
+ HistoryID: historyID,
+ RawRequest: rawRequest,
+ RawResponse: rawResponse,
+ }); err != nil {
+ logger.Error("loot: add: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+ }, l.win)
+ closeOnEscape(l.win, d.Dismiss)
+ d.Show()
+}
+
+func (l *lootTab) exportMarkdown() {
+ entries, err := l.projectStore.AllLoot()
+ if err != nil {
+ logger.Error("loot: export: %v", err)
+ return
+ }
+ if len(entries) == 0 {
+ dialog.ShowInformation("Export", "No loot entries to export.", l.win)
+ return
+ }
+
+ type entryData struct {
+ Severity string
+ Title string
+ Date string
+ Notes string
+ Request string
+ Response string
+ }
+ type templateData struct {
+ Generated string
+ Entries []entryData
+ }
+
+ data := templateData{Generated: time.Now().Format("2006-01-02 15:04")}
+ for _, entry := range entries {
+ ed := entryData{
+ Severity: entry.Severity,
+ Title: entry.Title,
+ Date: entry.CreatedAt.Format("2006-01-02 15:04"),
+ Notes: entry.Notes,
+ }
+ if entry.HistoryID != nil {
+ if tx, err := l.projectStore.GetTransaction(*entry.HistoryID); err == nil {
+ ed.Request = FormatStoreRequest(*tx)
+ ed.Response = FormatStoreResponse(*tx)
+ }
+ } else {
+ ed.Request = entry.RawRequest
+ ed.Response = entry.RawResponse
+ }
+ data.Entries = append(data.Entries, ed)
+ }
+
+ tmpl, err := template.New("findings").Parse(assets.FindingsTemplate)
+ if err != nil {
+ logger.Error("loot: parse template: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+
+ var builder strings.Builder
+ if err := tmpl.Execute(&builder, data); err != nil {
+ logger.Error("loot: execute template: %v", err)
+ dialog.ShowError(err, l.win)
+ return
+ }
+
+ saveDialog := dialog.NewFileSave(func(wc fyne.URIWriteCloser, err error) {
+ if err != nil || wc == nil {
+ return
+ }
+ defer wc.Close()
+ if _, err := wc.Write([]byte(builder.String())); err != nil {
+ logger.Error("loot: write export: %v", err)
+ dialog.ShowError(err, l.win)
+ }
+ }, l.win)
+ saveDialog.SetFileName(fmt.Sprintf("findings-%s.md", time.Now().Format("2006-01-02")))
+ saveDialog.Show()
+}
+
+func (l *lootTab) showLinkedRequest(entry store.LootEntry) {
+ if entry.HistoryID != nil {
+ go func() {
+ tx, err := l.projectStore.GetTransaction(*entry.HistoryID)
+ if err != nil {
+ logger.Error("loot: get linked request: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ l.showRequestResponseDialog(
+ fmt.Sprintf("Linked Request — %s %s", tx.Method, tx.URL),
+ FormatStoreRequest(*tx),
+ FormatStoreResponse(*tx),
+ tx.Host, tx.Port, tx.TLS,
+ )
+ })
+ }()
+ } else {
+ host, port, useTLS := internalhttp.ParseHostFromRaw(entry.RawRequest)
+ l.showRequestResponseDialog("Linked Request", entry.RawRequest, entry.RawResponse, host, port, useTLS)
+ }
+}
+
+func (l *lootTab) showRequestResponseDialog(title, rawRequest, rawResponse, host string, port int, useTLS bool) {
+ reqEntry := widgets.NewTextView()
+ reqEntry.SetWindow(l.win)
+ reqEntry.SetText(rawRequest)
+
+ respEntry := widgets.NewTextView()
+ respEntry.SetWindow(l.win)
+ respEntry.SetText(rawResponse)
+
+ sendBtn := widget.NewButtonWithIcon("Send to Repeater", theme.MailForwardIcon(), nil)
+
+ split := container.NewHSplit(
+ container.NewBorder(paneHeader("Request"), nil, nil, nil, reqEntry.Build()),
+ container.NewBorder(paneHeader("Response"), nil, nil, nil, respEntry.Build()),
+ )
+ split.SetOffset(0.5)
+
+ d := dialog.NewCustom(title, "Close",
+ container.NewBorder(nil, sendBtn, nil, nil, split),
+ l.win)
+
+ sendBtn.OnTapped = func() {
+ path := PathOf(internalhttp.ExtractURL(rawRequest))
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ l.repeater.AddTab(
+ fmt.Sprintf("%s %s", internalhttp.ExtractMethod(rawRequest), path),
+ host, port, useTLS, rawRequest,
+ )
+ d.Hide()
+ }
+
+ closeOnEscape(l.win, d.Dismiss)
+ d.Show()
+ d.Resize(fyne.NewSize(900, 600))
+}
diff --git a/internal/ui/plugins.go b/internal/ui/plugins.go
new file mode 100644
index 0000000..cf29f37
--- /dev/null
+++ b/internal/ui/plugins.go
@@ -0,0 +1,179 @@
+package ui
+
+import (
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+type pluginsTab struct {
+ projectStore *store.Store
+ bus *events.Bus
+ win fyne.Window
+}
+
+func newPluginsTab(projectStore *store.Store, bus *events.Bus, win fyne.Window) *pluginsTab {
+ return &pluginsTab{
+ projectStore: projectStore,
+ bus: bus,
+ win: win,
+ }
+}
+
+func (t *pluginsTab) build() fyne.CanvasObject {
+ var plugins []store.PluginEntry
+ selectedIdx := -1
+
+ logView := widgets.NewTextView()
+ logView.SetWindow(t.win)
+
+ toggleBtn := widget.NewButton("Disable", nil)
+ toggleBtn.Disable()
+
+ var pluginList *widget.List
+
+ refreshLogs := func() {
+ if selectedIdx < 0 || selectedIdx >= len(plugins) {
+ logView.SetText("")
+ return
+ }
+ lines := t.projectStore.PluginLogs(plugins[selectedIdx].Name)
+ logView.SetText(strings.Join(lines, "\n"))
+ }
+
+ refreshAll := func() {
+ entries, err := t.projectStore.AllPlugins()
+ if err != nil {
+ return
+ }
+ plugins = entries
+ pluginList.Refresh()
+
+ // Keep toggle button label in sync after external state change.
+ if selectedIdx >= 0 && selectedIdx < len(plugins) {
+ if plugins[selectedIdx].Enabled {
+ toggleBtn.SetText("Disable")
+ } else {
+ toggleBtn.SetText("Enable")
+ }
+ toggleBtn.Enable()
+ }
+
+ refreshLogs()
+ }
+
+ pluginList = widget.NewList(
+ func() int { return len(plugins) },
+ func() fyne.CanvasObject {
+ return container.NewHBox(
+ widget.NewLabel(""),
+ widget.NewLabel(""),
+ )
+ },
+ func(id widget.ListItemID, item fyne.CanvasObject) {
+ if id >= len(plugins) {
+ return
+ }
+ p := plugins[id]
+ box := item.(*fyne.Container)
+ status := box.Objects[0].(*widget.Label)
+ name := box.Objects[1].(*widget.Label)
+ if p.Enabled {
+ status.SetText("✓")
+ status.Importance = widget.SuccessImportance
+ } else {
+ status.SetText("✗")
+ status.Importance = widget.DangerImportance
+ }
+ name.SetText(p.Name)
+ },
+ )
+
+ pluginList.OnSelected = func(id widget.ListItemID) {
+ selectedIdx = id
+ if id >= len(plugins) {
+ toggleBtn.Disable()
+ return
+ }
+ if plugins[id].Enabled {
+ toggleBtn.SetText("Disable")
+ } else {
+ toggleBtn.SetText("Enable")
+ }
+ toggleBtn.Enable()
+ refreshLogs()
+ }
+
+ pluginList.OnUnselected = func(_ widget.ListItemID) {
+ selectedIdx = -1
+ toggleBtn.Disable()
+ logView.SetText("")
+ }
+
+ toggleBtn.OnTapped = func() {
+ if selectedIdx < 0 || selectedIdx >= len(plugins) {
+ return
+ }
+ p := plugins[selectedIdx]
+ t.bus.EmitSetPluginEnabled(events.SetPluginEnabledEvent{
+ Name: p.Name,
+ Enabled: !p.Enabled,
+ })
+ }
+
+ addBtn := widget.NewButton("Add", func() {
+ fd := dialog.NewFileOpen(func(uc fyne.URIReadCloser, err error) {
+ if err != nil || uc == nil {
+ return
+ }
+ uc.Close()
+ t.bus.EmitLoadPlugin(events.LoadPluginEvent{
+ SourcePath: uc.URI().Path(),
+ })
+ }, t.win)
+ fd.SetFilter(storage.NewExtensionFileFilter([]string{".lua"}))
+ fd.Show()
+ })
+
+ // Listen for plugin state and log changes from the store.
+ go func() {
+ for range t.projectStore.PluginEntries {
+ fyne.Do(refreshAll)
+ }
+ }()
+
+ // Initial population.
+ refreshAll()
+
+ toolbar := container.New(
+ layout.NewCustomPaddedLayout(8, 8, 0, 0),
+ container.NewHBox(addBtn, toggleBtn),
+ )
+
+ leftPane := container.NewBorder(
+ newBoldLabel("Plugins"),
+ toolbar,
+ nil, nil,
+ pluginList,
+ )
+
+ rightPane := container.NewBorder(
+ newBoldLabel("Log"),
+ nil, nil, nil,
+ logView.Build(),
+ )
+
+ split := container.NewHSplit(leftPane, rightPane)
+ split.SetOffset(0.3)
+
+ return split
+}
diff --git a/internal/ui/repeater.go b/internal/ui/repeater.go
new file mode 100644
index 0000000..72c61ae
--- /dev/null
+++ b/internal/ui/repeater.go
@@ -0,0 +1,224 @@
+package ui
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/widget"
+
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+)
+
+type repeaterTab struct {
+ projectStore *store.Store
+ bus *events.Bus
+ loot *lootTab
+ tabs *container.DocTabs
+ win fyne.Window
+ tabIDs map[*container.TabItem]int64
+ sendFns map[*container.TabItem]func()
+ loadFns map[*container.TabItem]func()
+}
+
+func newRepeaterTab(projectStore *store.Store, bus *events.Bus, win fyne.Window) *repeaterTab {
+ return &repeaterTab{
+ projectStore: projectStore,
+ bus: bus,
+ win: win,
+ tabIDs: make(map[*container.TabItem]int64),
+ sendFns: make(map[*container.TabItem]func()),
+ loadFns: make(map[*container.TabItem]func()),
+ }
+}
+
+func (r *repeaterTab) closeTab(closed *container.TabItem) {
+ if tabID, ok := r.tabIDs[closed]; ok {
+ if err := r.projectStore.DeleteRepeaterTab(tabID); err != nil {
+ logger.Error("repeater: delete tab: %v", err)
+ }
+ delete(r.tabIDs, closed)
+ }
+ delete(r.sendFns, closed)
+ delete(r.loadFns, closed)
+}
+
+func (r *repeaterTab) build() fyne.CanvasObject {
+ r.tabs = container.NewDocTabs()
+ r.tabs.SetTabLocation(container.TabLocationTop)
+
+ r.tabs.OnSelected = func(item *container.TabItem) {
+ if fn, ok := r.loadFns[item]; ok {
+ fn()
+ delete(r.loadFns, item)
+ }
+ }
+
+ r.tabs.OnClosed = r.closeTab
+
+ r.tabs.CreateTab = func() *container.TabItem {
+ saved := store.RepeaterTab{
+ Name: "New Tab",
+ Port: 443,
+ TabType: "http",
+ }
+ id, err := r.projectStore.SaveRepeaterTab(saved)
+ if err != nil {
+ logger.Error("repeater: save new tab: %v", err)
+ return container.NewTabItem("New Tab", widget.NewLabel(""))
+ }
+ saved.ID = id
+ return r.buildTabItem(saved)
+ }
+
+ saved, err := r.projectStore.AllRepeaterTabs()
+ if err != nil {
+ logger.Error("repeater: load tabs: %v", err)
+ }
+ for _, t := range saved {
+ r.tabs.Append(r.buildTabItem(t))
+ }
+ if selected := r.tabs.Selected(); selected != nil {
+ if fn, ok := r.loadFns[selected]; ok {
+ fn()
+ delete(r.loadFns, selected)
+ }
+ }
+
+ return r.tabs
+}
+
+func (r *repeaterTab) AddTab(name, host string, port int, useTLS bool, rawRequest string) {
+ saved := store.RepeaterTab{
+ Name: name,
+ Host: host,
+ Port: port,
+ TLS: useTLS,
+ TabType: "http",
+ RawRequest: rawRequest,
+ }
+ id, err := r.projectStore.SaveRepeaterTab(saved)
+ if err != nil {
+ logger.Error("repeater: save tab: %v", err)
+ return
+ }
+ saved.ID = id
+ item := r.buildTabItem(saved)
+ r.tabs.Append(item)
+ r.tabs.Select(item)
+}
+
+// AddWSTab opens a new WebSocket repeater tab for the given connection.
+func (r *repeaterTab) AddWSTab(name, wsURL string, useTLS bool) {
+ saved := store.RepeaterTab{
+ Name: name,
+ Host: wsURL,
+ TLS: useTLS,
+ TabType: "websocket",
+ RawRequest: wsURL,
+ }
+ id, err := r.projectStore.SaveRepeaterTab(saved)
+ if err != nil {
+ logger.Error("repeater: save ws tab: %v", err)
+ return
+ }
+ saved.ID = id
+ item := r.buildTabItem(saved)
+ r.tabs.Append(item)
+ r.tabs.Select(item)
+}
+
+func (r *repeaterTab) buildTabItem(tab store.RepeaterTab) *container.TabItem {
+ if tab.TabType == "websocket" {
+ return r.buildWSTabItem(tab)
+ }
+ return r.buildHTTPTabItem(tab)
+}
+
+// updateTabName updates the DocTab label after an HTTP send.
+func (r *repeaterTab) updateTabName(rawReq string, tabID int64) {
+ firstLine := strings.SplitN(rawReq, "\n", 2)[0]
+ parts := strings.Fields(firstLine)
+ if len(parts) < 2 {
+ return
+ }
+ path := parts[1]
+ if len(path) > 24 {
+ path = path[:24] + "..."
+ }
+ name := fmt.Sprintf("%s %s", parts[0], path)
+
+ for item, id := range r.tabIDs {
+ if id == tabID {
+ item.Text = name
+ r.tabs.Refresh()
+ break
+ }
+ }
+
+ if err := r.projectStore.RenameRepeaterTab(tabID, name); err != nil {
+ logger.Error("repeater: rename tab: %v", err)
+ }
+}
+
+// patchRaw applies plugin modifications surgically to the normalized raw
+// request string. Only fields the plugin actually changed are rewritten.
+// SendRaw owns Content-Length, Connection, and Accept-Encoding — we leave
+// those untouched so its internal rewriteHeaders handles them correctly.
+func patchRaw(raw, headerSection string, origBody []byte, origMethod, origPath string, result events.RequestResult) string {
+ lines := strings.Split(headerSection, "\r\n")
+ if len(lines) == 0 {
+ return raw
+ }
+
+ // Rewrite request line only if method or path actually changed.
+ newMethod := result.Request.Method
+ newPath := result.Request.URL.RequestURI()
+ if newMethod == "" {
+ newMethod = origMethod
+ }
+ if newPath == "" || newPath == "?" {
+ newPath = origPath
+ }
+ if newMethod != origMethod || newPath != origPath {
+ lines[0] = fmt.Sprintf("%s %s HTTP/1.1", newMethod, newPath)
+ }
+
+ // Apply header changes: update existing headers in-place (preserving
+ // original casing and order), append new ones at the end.
+ for pluginKey, pluginVals := range result.Request.Header {
+ if len(pluginVals) == 0 {
+ continue
+ }
+ pluginVal := strings.Join(pluginVals, ", ")
+ found := false
+ for i := 1; i < len(lines); i++ {
+ if lines[i] == "" {
+ break
+ }
+ parts := strings.SplitN(lines[i], ": ", 2)
+ if len(parts) == 2 && strings.EqualFold(parts[0], pluginKey) {
+ lines[i] = parts[0] + ": " + pluginVal
+ found = true
+ break
+ }
+ }
+ if !found {
+ lines = append(lines, pluginKey+": "+pluginVal)
+ }
+ }
+
+ // Replace body only if the plugin actually changed it.
+ _, body := internalhttp.SplitRaw(raw)
+ finalBody := body
+ if !bytes.Equal(result.Body, origBody) {
+ finalBody = result.Body
+ }
+
+ return strings.Join(lines, "\r\n") + "\r\n\r\n" + string(finalBody)
+}
diff --git a/internal/ui/repeater_http.go b/internal/ui/repeater_http.go
new file mode 100644
index 0000000..8204a6e
--- /dev/null
+++ b/internal/ui/repeater_http.go
@@ -0,0 +1,310 @@
+package ui
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/events"
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+type httpHistEntry struct{ req, resp string }
+
+type httpTabState struct {
+ r *repeaterTab
+ tab store.RepeaterTab
+ tabID int64
+ reqEditor *widgets.TextViewEntry
+ respLabel *widgets.TextView
+ sendBtn *widget.Button
+ inspectBtn *widget.Button
+ sendToLootBtn *widget.Button
+ prevBtn *widget.Button
+ nextBtn *widget.Button
+ cookieJar map[string]string
+ lastRawRequest string
+ lastRawResponse string
+ lastRespBody []byte
+ sendHistory []httpHistEntry
+ histIdx int
+}
+
+func (s *httpTabState) loadHistEntry(idx int) {
+ if idx < 0 || idx >= len(s.sendHistory) {
+ return
+ }
+ s.histIdx = idx
+ s.reqEditor.SetText(s.sendHistory[idx].req)
+ s.respLabel.SetText(s.sendHistory[idx].resp)
+ if s.histIdx > 0 {
+ s.prevBtn.Enable()
+ } else {
+ s.prevBtn.Disable()
+ }
+ if s.histIdx < len(s.sendHistory)-1 {
+ s.nextBtn.Enable()
+ } else {
+ s.nextBtn.Disable()
+ }
+}
+
+// applyBusRequest runs the bus plugin pipeline on rawReq. Returns dropped=true
+// when a plugin rejects the request (UI already updated); caller must return.
+// busOK=false (with dropped=false) means no bus or NewRequest failed — caller
+// should still send, just skip EmitResponse.
+func (s *httpTabState) applyBusRequest(normalized, headerSection string, rawBody []byte, rawMethod, rawPath, scheme, addr string) (finalRaw string, emitReq *http.Request, emitBody []byte, busOK, dropped bool) {
+ finalRaw = normalized
+ if s.r.bus == nil {
+ return
+ }
+ busReq, err := http.NewRequest(rawMethod, fmt.Sprintf("%s://%s%s", scheme, addr, rawPath), nil)
+ if err != nil {
+ return
+ }
+ busReq.Header = internalhttp.ParseRawHeaders(headerSection)
+ result := s.r.bus.EmitRequest(events.RequestEvent{Request: busReq, Body: rawBody})
+ if result.Drop {
+ fyne.Do(func() {
+ s.sendBtn.Enable()
+ s.respLabel.SetText("HTTP/1.1 403 Forbidden\r\n\r\nrequest dropped by plugin")
+ })
+ dropped = true
+ return
+ }
+ // Apply only what the plugin actually changed back onto
+ // the normalized raw string. SendRaw owns Content-Length,
+ // Connection, and Accept-Encoding — we never touch those.
+ finalRaw = patchRaw(normalized, headerSection, rawBody, rawMethod, rawPath, result)
+ return finalRaw, result.Request, result.Body, true, false
+}
+
+// applySendResult runs inside fyne.Do after a successful or failed send.
+func (s *httpTabState) applySendResult(rawReq string, sendResult *internalhttp.RawResponse, err error) {
+ s.sendBtn.Enable()
+ if err != nil {
+ s.respLabel.SetText("Error: " + err.Error())
+ logger.Error("repeater: send: %v", err)
+ return
+ }
+ s.respLabel.SetText(sendResult.Raw)
+ for _, c := range sendResult.Cookies {
+ s.cookieJar[c.Name] = c.Value
+ }
+ s.lastRawRequest = rawReq
+ s.lastRawResponse = sendResult.Raw
+ s.lastRespBody = sendResult.Body
+ s.r.updateTabName(rawReq, s.tabID)
+ s.inspectBtn.Enable()
+ s.sendToLootBtn.Enable()
+ if saveErr := s.r.projectStore.UpdateRepeaterTab(s.tabID, rawReq, sendResult.Raw); saveErr != nil {
+ logger.Error("repeater: update tab: %v", saveErr)
+ }
+
+ // Record in per-tab send history.
+ const maxHistory = 50
+ s.sendHistory = append(s.sendHistory, httpHistEntry{req: rawReq, resp: sendResult.Raw})
+ if len(s.sendHistory) > maxHistory {
+ s.sendHistory = s.sendHistory[len(s.sendHistory)-maxHistory:]
+ }
+ s.histIdx = len(s.sendHistory) - 1
+ if s.prevBtn != nil {
+ if s.histIdx > 0 {
+ s.prevBtn.Enable()
+ }
+ s.nextBtn.Disable()
+ }
+}
+
+func (s *httpTabState) doSend() {
+ if s.sendBtn.Disabled() {
+ return
+ }
+ rawReq := s.reqEditor.GetText()
+ host, port, useTLS := internalhttp.ParseHostFromRaw(rawReq)
+ if host == "" {
+ s.respLabel.SetText("Error: no Host header found in request")
+ return
+ }
+ // ParseHostFromRaw defaults to port 80 / plain HTTP when the Host header
+ // has no explicit port and the request line has no scheme prefix.
+ // Use the tab's saved port/TLS as the authoritative values in that case.
+ if port == 80 && !useTLS && s.tab.Port != 0 {
+ port = s.tab.Port
+ useTLS = s.tab.TLS
+ }
+ s.sendBtn.Disable()
+ go func() {
+ start := time.Now()
+ scheme := "http"
+ if useTLS {
+ scheme = "https"
+ }
+ addr := fmt.Sprintf("%s:%d", host, port)
+
+ // normalizeRaw ensures CRLF in headers before any parsing.
+ // SendRaw does the same internally — we must match it here so
+ // that our header/body split is consistent with what it sends.
+ normalized := internalhttp.NormalizeLineEndings(rawReq)
+ headerSection, rawBody := internalhttp.SplitRaw(normalized)
+ rawMethod := internalhttp.ExtractMethod(normalized)
+ rawPath := internalhttp.ExtractURL(normalized)
+
+ finalRawReq, emitReq, emitBody, busOK, dropped := s.applyBusRequest(normalized, headerSection, rawBody, rawMethod, rawPath, scheme, addr)
+ if dropped {
+ return
+ }
+
+ sendResult, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host,
+ Port: port,
+ TLS: useTLS,
+ RawReq: finalRawReq,
+ CookieJar: s.cookieJar,
+ })
+
+ elapsed := time.Since(start).Milliseconds()
+
+ if err == nil && busOK {
+ s.r.bus.EmitResponse(events.ResponseEvent{
+ Timestamp: start,
+ Host: addr,
+ Proto: "HTTP/1.1",
+ Method: emitReq.Method,
+ URL: emitReq.URL.String(),
+ ReqHeaders: emitReq.Header,
+ ReqBody: emitBody,
+ StatusCode: sendResult.StatusCode,
+ RespHeaders: internalhttp.ParseRawHeaders(sendResult.Raw),
+ RespBody: sendResult.Body,
+ DurationMs: elapsed,
+ TLS: useTLS,
+ })
+ }
+
+ fyne.Do(func() { s.applySendResult(rawReq, sendResult, err) })
+ }()
+}
+
+func (s *httpTabState) buildEditors() {
+ s.reqEditor = widgets.NewTextViewEntry()
+ s.reqEditor.SetWindow(s.r.win)
+ s.reqEditor.SetPlaceHolder("Paste or edit raw HTTP request here...")
+
+ s.respLabel = widgets.NewTextView()
+ s.respLabel.SetWindow(s.r.win)
+}
+
+func (s *httpTabState) buildActionButtons() {
+ s.inspectBtn = widget.NewButtonWithIcon("Inspector", AppIcon("inspector"), func() {
+ _, reqBody := internalhttp.SplitRaw(s.lastRawRequest)
+ showInspectorDialog(store.Transaction{
+ ReqHeaders: internalhttp.ParseRawHeaders(s.lastRawRequest),
+ ReqBody: reqBody,
+ RespHeaders: internalhttp.ParseRawHeaders(s.lastRawResponse),
+ RespBody: s.lastRespBody,
+ }, s.r.win)
+ })
+ s.inspectBtn.Disable()
+
+ s.sendToLootBtn = widget.NewButtonWithIcon("Loot", AppIcon("loot"), func() {
+ if s.r.loot != nil {
+ s.r.loot.showAddDialog(nil, s.lastRawRequest, s.lastRawResponse)
+ }
+ })
+ s.sendToLootBtn.Disable()
+}
+
+func (s *httpTabState) buildSendHistory() {
+ s.prevBtn = widget.NewButton("◀", func() { s.loadHistEntry(s.histIdx - 1) })
+ s.nextBtn = widget.NewButton("▶", func() { s.loadHistEntry(s.histIdx + 1) })
+ s.prevBtn.Disable()
+ s.nextBtn.Disable()
+}
+
+func (s *httpTabState) buildToolbar() fyne.CanvasObject {
+ cloneBtn := widget.NewButtonWithIcon("Clone", theme.ContentCopyIcon(), func() {
+ raw := s.reqEditor.GetText()
+ firstLine := strings.SplitN(raw, "\n", 2)[0]
+ parts := strings.Fields(firstLine)
+ name := s.tab.Name
+ if len(parts) >= 2 {
+ path := parts[1]
+ if len(path) > 20 {
+ path = path[:20] + "..."
+ }
+ name = fmt.Sprintf("%s %s", parts[0], path)
+ }
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ s.r.AddTab(name, host, port, useTLS, raw)
+ })
+
+ return container.NewBorder(nil, nil,
+ container.NewHBox(s.sendBtn, cloneBtn, s.prevBtn, s.nextBtn),
+ s.sendToLootBtn,
+ widget.NewLabel(""),
+ )
+}
+
+func (s *httpTabState) buildLayout() fyne.CanvasObject {
+ toolbar := s.buildToolbar()
+
+ copyReqBtn := widget.NewButton("Copy", func() {
+ fyne.CurrentApp().Clipboard().SetContent(s.reqEditor.GetText())
+ })
+ copyRespBtn := widget.NewButton("Copy", func() {
+ fyne.CurrentApp().Clipboard().SetContent(s.lastRawResponse)
+ })
+
+ reqPane := container.NewBorder(
+ paneHeader("Request", copyReqBtn),
+ nil, nil, nil, s.reqEditor.Build(),
+ )
+ respPane := container.NewBorder(
+ paneHeader("Response", copyRespBtn, s.inspectBtn),
+ nil, nil, nil, s.respLabel.Build(),
+ )
+
+ split := container.NewHSplit(reqPane, respPane)
+ split.SetOffset(0.5)
+
+ return container.NewBorder(container.New(layout.NewCustomPaddedLayout(8, 0, 0, 0), toolbar), nil, nil, nil, split)
+}
+
+func (r *repeaterTab) buildHTTPTabItem(tab store.RepeaterTab) *container.TabItem {
+ s := &httpTabState{
+ r: r,
+ tab: tab,
+ tabID: tab.ID,
+ cookieJar: make(map[string]string),
+ histIdx: -1,
+ }
+
+ s.buildEditors()
+ s.buildActionButtons()
+ s.buildSendHistory()
+
+ s.sendBtn = widget.NewButtonWithIcon("Send", theme.MailSendIcon(), nil)
+ s.sendBtn.Importance = widget.HighImportance
+ s.sendBtn.OnTapped = s.doSend
+
+ content := s.buildLayout()
+ tabItem := container.NewTabItem(tab.Name, content)
+
+ r.sendFns[tabItem] = s.doSend
+ r.tabIDs[tabItem] = s.tabID
+ s.reqEditor.SetText(tab.RawRequest)
+ r.loadFns[tabItem] = func() {}
+
+ return tabItem
+}
diff --git a/internal/ui/repeater_test.go b/internal/ui/repeater_test.go
new file mode 100644
index 0000000..c2c54af
--- /dev/null
+++ b/internal/ui/repeater_test.go
@@ -0,0 +1,378 @@
+package ui
+
+import (
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gorilla/websocket"
+ internalhttp "github.com/shiv/internal/http"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── ParseHostFromRaw ──────────────────────────────────────────────────────────
+
+func TestParseHostFromRaw_HostOnly(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+ assert.Equal(t, 80, port)
+ assert.False(t, useTLS)
+}
+
+func TestParseHostFromRaw_HostWithPort80(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nHost: example.com:80\r\n\r\n"
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+ assert.Equal(t, 80, port)
+ assert.False(t, useTLS)
+}
+
+func TestParseHostFromRaw_HostWithPort443(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nHost: example.com:443\r\n\r\n"
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+ assert.Equal(t, 443, port)
+ assert.True(t, useTLS)
+}
+
+func TestParseHostFromRaw_CustomPort(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+ assert.Equal(t, 8080, port)
+ assert.False(t, useTLS)
+}
+
+func TestParseHostFromRaw_NoHostHeader(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nContent-Type: text/plain\r\n\r\n"
+ host, port, useTLS := internalhttp.ParseHostFromRaw(raw)
+ assert.Empty(t, host)
+ assert.Equal(t, 0, port)
+ assert.False(t, useTLS)
+}
+
+func TestParseHostFromRaw_CaseInsensitive(t *testing.T) {
+ raw := "GET / HTTP/1.1\r\nHOST: example.com\r\n\r\n"
+ host, _, _ := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+}
+
+func TestParseHostFromRaw_LFOnly(t *testing.T) {
+ raw := "GET / HTTP/1.1\nHost: example.com\n\n"
+ host, port, _ := internalhttp.ParseHostFromRaw(raw)
+ assert.Equal(t, "example.com", host)
+ assert.Equal(t, 80, port)
+}
+
+// ── Decompress ────────────────────────────────────────────────────────────────
+
+func TestDecompressRepeaterBody_Gzip(t *testing.T) {
+ var buf bytes.Buffer
+ w := gzip.NewWriter(&buf)
+ _, err := w.Write([]byte("hello gzip"))
+ require.NoError(t, err)
+ w.Close()
+
+ hdr := http.Header{"Content-Encoding": []string{"gzip"}}
+ out := internalhttp.Decompress(hdr, buf.Bytes())
+ assert.Equal(t, "hello gzip", string(out))
+}
+
+func TestDecompressRepeaterBody_NoEncoding(t *testing.T) {
+ body := []byte("plain body")
+ out := internalhttp.Decompress(http.Header{}, body)
+ assert.Equal(t, body, out)
+}
+
+func TestDecompressRepeaterBody_BadGzip(t *testing.T) {
+ body := []byte("not gzip data")
+ hdr := http.Header{"Content-Encoding": []string{"gzip"}}
+ out := internalhttp.Decompress(hdr, body)
+ assert.Equal(t, body, out)
+}
+
+// ── SendRaw ───────────────────────────────────────────────────────────────────
+
+func startMockHTTPServer(t *testing.T, response string) (host string, port int) {
+ t.Helper()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf := make([]byte, 4096)
+ conn.Read(buf)
+ fmt.Fprint(conn, response)
+ }()
+
+ t.Cleanup(func() { ln.Close() })
+
+ addr := ln.Addr().(*net.TCPAddr)
+ return "127.0.0.1", addr.Port
+}
+
+func TestSendRawRequest_200OK(t *testing.T) {
+ body := "Hello, World!"
+ mockResp := fmt.Sprintf(
+ "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n%s",
+ len(body), body,
+ )
+ host, port := startMockHTTPServer(t, mockResp)
+
+ raw := fmt.Sprintf("GET / HTTP/1.1\r\nHost: %s:%d\r\n\r\n", host, port)
+ result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host, Port: port, TLS: false, RawReq: raw,
+ })
+ require.NoError(t, err)
+ assert.Contains(t, result.Raw, "200 OK")
+ assert.Contains(t, result.Raw, "Hello, World!")
+}
+
+func TestSendRawRequest_404(t *testing.T) {
+ mockResp := "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
+ host, port := startMockHTTPServer(t, mockResp)
+
+ raw := fmt.Sprintf("GET /missing HTTP/1.1\r\nHost: %s:%d\r\n\r\n", host, port)
+ result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host, Port: port, TLS: false, RawReq: raw,
+ })
+ require.NoError(t, err)
+ assert.Contains(t, result.Raw, "404")
+}
+
+func TestSendRawRequest_WithBody(t *testing.T) {
+ mockResp := "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"
+ host, port := startMockHTTPServer(t, mockResp)
+
+ raw := fmt.Sprintf(
+ "POST /api HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\n\r\n{\"key\":\"val\"}",
+ host, port,
+ )
+ result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host, Port: port, TLS: false, RawReq: raw,
+ })
+ require.NoError(t, err)
+ assert.Contains(t, result.Raw, "200 OK")
+}
+
+func TestSendRawRequest_ConnectionRefused(t *testing.T) {
+ _, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: "127.0.0.1", Port: 1, TLS: false,
+ RawReq: "GET / HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n",
+ })
+ assert.Error(t, err)
+}
+
+func TestSendRawRequest_StripAcceptEncoding(t *testing.T) {
+ received := make(chan string, 1)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf := make([]byte, 4096)
+ n, _ := conn.Read(buf)
+ received <- string(buf[:n])
+ fmt.Fprint(conn, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
+ }()
+
+ addr := ln.Addr().(*net.TCPAddr)
+ raw := fmt.Sprintf(
+ "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nAccept-Encoding: gzip, deflate\r\n\r\n",
+ addr.Port,
+ )
+ internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: "127.0.0.1", Port: addr.Port, TLS: false, RawReq: raw,
+ })
+
+ req := <-received
+ assert.NotContains(t, req, "Accept-Encoding")
+}
+
+func TestSendRawRequest_MultipleRequests_IndependentConnections(t *testing.T) {
+ // Verify that two sequential SendRaw calls use independent connections
+ // and both get correct responses.
+ responses := []string{
+ "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfirst",
+ "HTTP/1.1 201 Created\r\nContent-Length: 6\r\n\r\nsecond",
+ }
+
+ for i, expected := range responses {
+ host, port := startMockHTTPServer(t, expected)
+ raw := fmt.Sprintf("GET / HTTP/1.1\r\nHost: %s:%d\r\n\r\n", host, port)
+ result, err := internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: host, Port: port, TLS: false, RawReq: raw,
+ })
+ require.NoError(t, err, "request %d failed", i)
+ if i == 0 {
+ assert.Contains(t, result.Raw, "200")
+ assert.Contains(t, result.Raw, "first")
+ } else {
+ assert.Contains(t, result.Raw, "201")
+ assert.Contains(t, result.Raw, "second")
+ }
+ }
+}
+
+func TestSendRawRequest_CookieJarApplied(t *testing.T) {
+ received := make(chan string, 1)
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf := make([]byte, 4096)
+ n, _ := conn.Read(buf)
+ received <- string(buf[:n])
+ fmt.Fprint(conn, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
+ }()
+
+ addr := ln.Addr().(*net.TCPAddr)
+ raw := fmt.Sprintf("GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", addr.Port)
+ internalhttp.SendRaw(internalhttp.RawRequestOptions{
+ Host: "127.0.0.1",
+ Port: addr.Port,
+ TLS: false,
+ RawReq: raw,
+ CookieJar: map[string]string{"session": "abc123", "user": "test"},
+ })
+
+ req := <-received
+ assert.Contains(t, req, "Cookie:")
+ assert.Contains(t, req, "session=abc123")
+}
+
+// ── WebSocket send via repeater ───────────────────────────────────────────────
+
+// wsEchoServer starts a plain (non-TLS) WebSocket echo server for repeater tests.
+func wsEchoServer(t *testing.T) string {
+ t.Helper()
+ upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ for {
+ mt, msg, err := conn.ReadMessage()
+ if err != nil {
+ return
+ }
+ if err := conn.WriteMessage(mt, msg); err != nil {
+ return
+ }
+ }
+ }))
+ t.Cleanup(srv.Close)
+ // Convert http:// to ws://
+ return strings.Replace(srv.URL, "http://", "ws://", 1)
+}
+
+func TestRepeaterWS_SendRaw_EchoesMessage(t *testing.T) {
+ wsURL := wsEchoServer(t)
+
+ dialer := websocket.Dialer{}
+ conn, _, err := dialer.Dial(wsURL+"/", nil)
+ require.NoError(t, err)
+ defer conn.Close()
+
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte("repeater test")))
+ _, payload, err := conn.ReadMessage()
+ require.NoError(t, err)
+ assert.Equal(t, "repeater test", string(payload))
+}
+
+func TestRepeaterWS_SendRaw_MultipleMessages(t *testing.T) {
+ wsURL := wsEchoServer(t)
+
+ dialer := websocket.Dialer{}
+ conn, _, err := dialer.Dial(wsURL+"/", nil)
+ require.NoError(t, err)
+ defer conn.Close()
+
+ messages := []string{"first", "second", "third"}
+ for _, msg := range messages {
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(msg)))
+ _, payload, err := conn.ReadMessage()
+ require.NoError(t, err)
+ assert.Equal(t, msg, string(payload))
+ }
+}
+
+func TestRepeaterWS_SendRaw_EmptyMessage(t *testing.T) {
+ wsURL := wsEchoServer(t)
+
+ dialer := websocket.Dialer{}
+ conn, _, err := dialer.Dial(wsURL+"/", nil)
+ require.NoError(t, err)
+ defer conn.Close()
+
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte("")))
+ _, payload, err := conn.ReadMessage()
+ require.NoError(t, err)
+ assert.Equal(t, "", string(payload))
+}
+
+func TestRepeaterWS_SendRaw_BinaryMessage(t *testing.T) {
+ wsURL := wsEchoServer(t)
+
+ dialer := websocket.Dialer{}
+ conn, _, err := dialer.Dial(wsURL+"/", nil)
+ require.NoError(t, err)
+ defer conn.Close()
+
+ payload := []byte{0x00, 0x01, 0x02, 0xFF}
+ require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, payload))
+ msgType, received, err := conn.ReadMessage()
+ require.NoError(t, err)
+ assert.Equal(t, websocket.BinaryMessage, msgType)
+ assert.Equal(t, payload, received)
+}
+
+func TestRepeaterWS_ConnectionRefused(t *testing.T) {
+ dialer := websocket.Dialer{}
+ _, _, err := dialer.Dial("ws://127.0.0.1:1/", nil)
+ assert.Error(t, err)
+}
+
+func TestRepeaterWS_CleanClose(t *testing.T) {
+ wsURL := wsEchoServer(t)
+
+ dialer := websocket.Dialer{}
+ conn, _, err := dialer.Dial(wsURL+"/", nil)
+ require.NoError(t, err)
+
+ // Send a message, receive echo, then close cleanly.
+ require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte("bye")))
+ _, payload, err := conn.ReadMessage()
+ require.NoError(t, err)
+ assert.Equal(t, "bye", string(payload))
+
+ // Clean close.
+ err = conn.WriteMessage(websocket.CloseMessage,
+ websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
+ require.NoError(t, err)
+ conn.Close()
+}
diff --git a/internal/ui/repeater_ws.go b/internal/ui/repeater_ws.go
new file mode 100644
index 0000000..4a61263
--- /dev/null
+++ b/internal/ui/repeater_ws.go
@@ -0,0 +1,418 @@
+package ui
+
+import (
+ "crypto/tls"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+type wsConnState int
+
+const (
+ wsDisconnected wsConnState = iota
+ wsConnecting
+ wsConnected
+)
+
+type wsFrame struct {
+ direction string
+ payload string
+ ts time.Time
+}
+
+type wsHeaderRow struct {
+ key string
+ value string
+}
+
+type wsTabState struct {
+ r *repeaterTab
+ tab store.RepeaterTab
+ tabID int64
+ wsConn *websocket.Conn
+ connState wsConnState
+ connID uint64
+ customHeaders []wsHeaderRow
+ frames []wsFrame
+ frameTable *widgets.DataTable
+ payloadView *widgets.TextView
+ connectBtn *widget.Button
+ sendFrameBtn *widget.Button
+ urlEntry *widget.Entry
+}
+
+func (s *wsTabState) disconnect() {
+ if s.wsConn != nil {
+ s.wsConn.Close()
+ s.wsConn = nil
+ }
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+}
+
+func (s *wsTabState) updateConnectBtn() {
+ switch s.connState {
+ case wsDisconnected:
+ s.connectBtn.SetText("Connect")
+ s.connectBtn.Importance = widget.HighImportance
+ s.connectBtn.Enable()
+ s.sendFrameBtn.Disable()
+ case wsConnecting:
+ s.connectBtn.SetText("Connecting...")
+ s.connectBtn.Importance = widget.MediumImportance
+ s.connectBtn.Disable()
+ s.sendFrameBtn.Disable()
+ case wsConnected:
+ s.connectBtn.SetText("Disconnect")
+ s.connectBtn.Importance = widget.DangerImportance
+ s.connectBtn.Enable()
+ s.sendFrameBtn.Enable()
+ }
+ s.connectBtn.Refresh()
+}
+
+func (s *wsTabState) startReadLoop(conn *websocket.Conn) {
+ go func() {
+ for {
+ msgType, payload, err := conn.ReadMessage()
+ if err != nil {
+ fyne.Do(func() {
+ if s.wsConn == conn {
+ s.wsConn = nil
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+ s.frames = append(s.frames, wsFrame{
+ direction: "⚠ Closed",
+ payload: err.Error(),
+ ts: time.Now(),
+ })
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ }
+ })
+ return
+ }
+ finalPayload := payload
+ if s.r.bus != nil {
+ result := s.r.bus.EmitWebSocketFrame(events.WebSocketFrameEvent{
+ ConnectionID: s.connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketServer,
+ Opcode: events.WebSocketOpcode(msgType),
+ Payload: payload,
+ })
+ finalPayload = result.Payload
+ }
+ f := wsFrame{direction: "← Recv", payload: string(finalPayload), ts: time.Now()}
+ fyne.Do(func() {
+ s.frames = append(s.frames, f)
+ s.frameTable.Refresh()
+ s.frameTable.ScrollToRow(len(s.frames) - 1)
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ })
+ }
+ }()
+}
+
+func (s *wsTabState) showHeadersDialog() {
+ editHeaders := make([]wsHeaderRow, len(s.customHeaders))
+ copy(editHeaders, s.customHeaders)
+
+ var rows *fyne.Container
+ var rebuildRows func()
+
+ rebuildRows = func() {
+ rows.RemoveAll()
+ for i := range editHeaders {
+ idx := i
+ keyEntry := widget.NewEntry()
+ keyEntry.SetPlaceHolder("Header-Name")
+ keyEntry.SetText(editHeaders[idx].key)
+ keyEntry.OnChanged = func(v string) { editHeaders[idx].key = v }
+
+ valEntry := widget.NewEntry()
+ valEntry.SetPlaceHolder("value")
+ valEntry.SetText(editHeaders[idx].value)
+ valEntry.OnChanged = func(v string) { editHeaders[idx].value = v }
+
+ deleteBtn := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
+ editHeaders = append(editHeaders[:idx], editHeaders[idx+1:]...)
+ rebuildRows()
+ })
+
+ rows.Add(container.NewBorder(nil, nil, nil, deleteBtn,
+ container.NewGridWithColumns(2, keyEntry, valEntry),
+ ))
+ }
+ rows.Refresh()
+ }
+
+ rows = container.NewVBox()
+ rebuildRows()
+
+ addBtn := widget.NewButtonWithIcon("Add Header", theme.ContentAddIcon(), func() {
+ editHeaders = append(editHeaders, wsHeaderRow{})
+ rebuildRows()
+ })
+
+ scrolled := container.NewVScroll(rows)
+ scrolled.SetMinSize(fyne.NewSize(500, 240))
+
+ body := container.NewBorder(nil, addBtn, nil, nil, scrolled)
+
+ dialog.ShowCustomConfirm("Edit Request Headers", "Save", "Cancel", body, func(save bool) {
+ if !save {
+ return
+ }
+ s.customHeaders = s.customHeaders[:0]
+ for _, h := range editHeaders {
+ if strings.TrimSpace(h.key) != "" {
+ s.customHeaders = append(s.customHeaders, h)
+ }
+ }
+ }, s.r.win)
+}
+
+func (s *wsTabState) buildFrameTable() {
+ frameColumns := []widgets.DataTableColumn{
+ {Header: "Dir", Width: 80},
+ {Header: "Time", Width: 120},
+ {Header: "Payload", Width: 300},
+ }
+
+ s.frameTable = widgets.NewDataTable()
+ s.frameTable.SetWindow(s.r.win)
+ s.frameTable.Columns = frameColumns
+ s.frameTable.RowCount = func() int { return len(s.frames) }
+ s.frameTable.RowID = func(row int) int64 { return int64(row) }
+ s.frameTable.CellValue = func(row, col int) string {
+ if row >= len(s.frames) {
+ return ""
+ }
+ f := s.frames[row]
+ switch col {
+ case 0:
+ return f.direction
+ case 1:
+ return f.ts.Format("15:04:05.000")
+ case 2:
+ return f.payload
+ }
+ return ""
+ }
+ s.frameTable.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(s.frames) {
+ return widget.MediumImportance
+ }
+ if s.frames[row].direction == "→ Sent" {
+ return widget.HighImportance
+ }
+ return widget.SuccessImportance
+ }
+}
+
+func (s *wsTabState) buildPayloadView() {
+ s.payloadView = widgets.NewTextView()
+ s.payloadView.SetWindow(s.r.win)
+ s.payloadView.SetPlaceHolder("Select a frame to view full payload...")
+
+ s.frameTable.OnSelect = func(row int) {
+ if row >= len(s.frames) {
+ return
+ }
+ p := s.frames[row].payload
+ if p == "" {
+ s.payloadView.SetText("(empty)")
+ } else {
+ s.payloadView.SetText(p)
+ }
+ }
+}
+
+func (s *wsTabState) buildConnectHandler() {
+ s.connectBtn.OnTapped = func() {
+ if s.connState == wsConnected {
+ s.disconnect()
+ return
+ }
+ rawURL := strings.TrimSpace(s.urlEntry.Text)
+ if rawURL == "" {
+ return
+ }
+ s.connState = wsConnecting
+ s.updateConnectBtn()
+ if err := s.r.projectStore.UpdateRepeaterTab(s.tabID, rawURL, ""); err != nil {
+ logger.Error("repeater: update ws tab: %v", err)
+ }
+
+ reqHeader := http.Header{}
+ for _, h := range s.customHeaders {
+ if strings.TrimSpace(h.key) != "" {
+ cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(h.value)
+ reqHeader.Set(h.key, cleaned)
+ }
+ }
+
+ go func() {
+ dialer := websocket.Dialer{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
+ HandshakeTimeout: 10 * time.Second,
+ NetDial: func(network, addr string) (net.Conn, error) {
+ return net.DialTimeout(network, addr, 10*time.Second)
+ },
+ }
+ conn, _, err := dialer.Dial(rawURL, reqHeader)
+ fyne.Do(func() {
+ if err != nil {
+ s.connState = wsDisconnected
+ s.updateConnectBtn()
+ s.frames = append(s.frames, wsFrame{direction: "⚠ Error", payload: err.Error(), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ return
+ }
+ s.wsConn = conn
+ s.connState = wsConnected
+ s.updateConnectBtn()
+ if s.r.bus != nil {
+ s.connID = s.r.bus.EmitWebSocketConnection(events.WebSocketConnectionEvent{
+ Host: rawURL,
+ URL: rawURL,
+ TLS: strings.HasPrefix(rawURL, "wss://"),
+ Timestamp: time.Now(),
+ })
+ }
+ s.frames = append(s.frames, wsFrame{direction: "✓ Connected", payload: rawURL, ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ s.startReadLoop(conn)
+ })
+ }()
+ }
+}
+
+func (s *wsTabState) buildSendHandler(payloadEntry *widget.Entry) {
+ s.sendFrameBtn.OnTapped = func() {
+ if s.wsConn == nil || s.connState != wsConnected {
+ return
+ }
+ payload := strings.ReplaceAll(strings.ReplaceAll(payloadEntry.Text, "\r\n", ""), "\n", "")
+ if payload == "" {
+ return
+ }
+ conn := s.wsConn
+ go func() {
+ finalPayload := []byte(payload)
+ if s.r.bus != nil {
+ result := s.r.bus.EmitWebSocketFrame(events.WebSocketFrameEvent{
+ ConnectionID: s.connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: finalPayload,
+ })
+ finalPayload = result.Payload
+ }
+ err := conn.WriteMessage(websocket.TextMessage, finalPayload)
+ logger.Info("ws: sending payload bytes: %q", finalPayload)
+ fyne.Do(func() {
+ if err != nil {
+ s.frames = append(s.frames, wsFrame{direction: "⚠ Error", payload: "send failed: " + err.Error(), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ return
+ }
+ s.frames = append(s.frames, wsFrame{direction: "→ Sent", payload: string(finalPayload), ts: time.Now()})
+ s.frameTable.Refresh()
+ s.frameTable.ScrollToRow(len(s.frames) - 1)
+ s.frameTable.OnSelect(len(s.frames) - 1)
+ })
+ }()
+ }
+}
+
+func (s *wsTabState) buildLayout(payloadEntry *widget.Entry, clearBtn, headersBtn *widget.Button) fyne.CanvasObject {
+ toolbar := container.NewBorder(nil, nil,
+ container.NewHBox(s.connectBtn, clearBtn, headersBtn),
+ nil,
+ s.urlEntry,
+ )
+
+ sendBar := container.NewBorder(nil, nil, nil, s.sendFrameBtn, payloadEntry)
+
+ framePane := container.NewBorder(paneHeader("Frames"), nil, nil, nil, s.frameTable.Build())
+ payloadPane := container.NewBorder(paneHeader("Payload"), nil, nil, nil, s.payloadView.Build())
+
+ frameSplit := container.NewVSplit(framePane, payloadPane)
+ frameSplit.SetOffset(0.6)
+
+ return container.NewBorder(
+ container.New(layout.NewCustomPaddedLayout(8, 0, 0, 0), toolbar),
+ container.NewBorder(nil, nil, nil, nil, sendBar),
+ nil, nil,
+ frameSplit,
+ )
+}
+
+func (r *repeaterTab) buildWSTabItem(tab store.RepeaterTab) *container.TabItem {
+ s := &wsTabState{
+ r: r,
+ tab: tab,
+ tabID: tab.ID,
+ }
+
+ s.urlEntry = widget.NewEntry()
+ s.urlEntry.SetPlaceHolder("wss://host:port/path")
+ if tab.RawRequest != "" {
+ s.urlEntry.SetText(tab.RawRequest)
+ }
+
+ s.connectBtn = widget.NewButtonWithIcon("Connect", AppIcon("web"), nil)
+ s.connectBtn.Importance = widget.HighImportance
+
+ s.sendFrameBtn = widget.NewButtonWithIcon("Send", theme.MailSendIcon(), nil)
+ s.sendFrameBtn.Disable()
+
+ s.buildFrameTable()
+ s.buildPayloadView()
+ s.buildConnectHandler()
+
+ payloadEntry := widget.NewMultiLineEntry()
+ payloadEntry.SetPlaceHolder("Message to send...")
+ payloadEntry.SetMinRowsVisible(3)
+
+ s.buildSendHandler(payloadEntry)
+
+ clearBtn := widget.NewButtonWithIcon("Clear", AppIcon("delete"), func() {
+ s.frames = nil
+ s.frameTable.Refresh()
+ s.payloadView.SetText("")
+ })
+
+ headersBtn := widget.NewButtonWithIcon("Headers", theme.ListIcon(), func() {
+ s.showHeadersDialog()
+ })
+
+ content := s.buildLayout(payloadEntry, clearBtn, headersBtn)
+ tabItem := container.NewTabItem(tab.Name, content)
+ r.tabIDs[tabItem] = s.tabID
+ r.loadFns[tabItem] = func() {}
+
+ return tabItem
+}
diff --git a/internal/ui/scope.go b/internal/ui/scope.go
new file mode 100644
index 0000000..037472c
--- /dev/null
+++ b/internal/ui/scope.go
@@ -0,0 +1,126 @@
+package ui
+
+import (
+ "net"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+)
+
+func showScopeDialog(projectStore *store.Store, win fyne.Window, onChanged func()) {
+ entries, err := projectStore.AllScopeEntries()
+ if err != nil {
+ logger.Error("scope: load entries: %v", err)
+ entries = nil
+ }
+
+ // we keep a local copy to drive the list
+ type row struct {
+ id int64
+ host string
+ }
+ rows := make([]row, len(entries))
+ for i, entry := range entries {
+ rows[i] = row{entry.ID, entry.Host}
+ }
+
+ var list *widget.List
+ var deleteBtn *widget.Button
+ selectedIdx := -1
+
+ list = widget.NewList(
+ func() int { return len(rows) },
+ func() fyne.CanvasObject {
+ return widget.NewLabel("")
+ },
+ func(i widget.ListItemID, obj fyne.CanvasObject) {
+ obj.(*widget.Label).SetText(rows[i].host)
+ },
+ )
+ list.OnSelected = func(i widget.ListItemID) {
+ selectedIdx = i
+ deleteBtn.Enable()
+ }
+ list.OnUnselected = func(_ widget.ListItemID) {
+ selectedIdx = -1
+ deleteBtn.Disable()
+ }
+
+ hostEntry := widget.NewEntry()
+ hostEntry.SetPlaceHolder("example.com")
+
+ addBtn := widget.NewButtonWithIcon("Add", AppIcon("save"), func() {
+ host := strings.TrimSpace(hostEntry.Text)
+ if host == "" {
+ return
+ }
+ // strip protocol if someone pastes a URL
+ host = strings.TrimPrefix(host, "https://")
+ host = strings.TrimPrefix(host, "http://")
+ // strip path
+ if idx := strings.Index(host, "/"); idx >= 0 {
+ host = host[:idx]
+ }
+ // strip port, avoid breaking ipv6
+ if hostname, _, err := net.SplitHostPort(host); err == nil {
+ host = hostname
+ }
+ if err := projectStore.AddScopeEntry(host); err != nil {
+ logger.Error("scope: add entry: %v", err)
+ return
+ }
+ newEntries, _ := projectStore.AllScopeEntries()
+ rows = make([]row, len(newEntries))
+ for i, entry := range newEntries {
+ rows[i] = row{entry.ID, entry.Host}
+ }
+ hostEntry.SetText("")
+ list.Refresh()
+ onChanged()
+ })
+ addBtn.Importance = widget.HighImportance
+
+ deleteBtn = widget.NewButtonWithIcon("Remove", theme.DeleteIcon(), func() {
+ if selectedIdx < 0 || selectedIdx >= len(rows) {
+ return
+ }
+ entryId := rows[selectedIdx].id
+ if err := projectStore.DeleteScopeEntry(entryId); err != nil {
+ logger.Error("scope: delete entry: %v", err)
+ return
+ }
+ newEntries, _ := projectStore.AllScopeEntries()
+ rows = make([]row, len(newEntries))
+ for i, e := range newEntries {
+ rows[i] = row{e.ID, e.Host}
+ }
+ selectedIdx = -1
+ deleteBtn.Disable()
+ list.Refresh()
+ onChanged()
+ })
+ deleteBtn.Disable()
+
+ inputRow := container.NewBorder(nil, nil, nil, addBtn, hostEntry)
+
+ content := container.NewBorder(
+ inputRow,
+ deleteBtn,
+ nil, nil,
+ list,
+ )
+ content.Resize(fyne.NewSize(400, 300))
+
+ scopeDialog := dialog.NewCustom("Scope", "Close", content, win)
+ scopeDialog.Resize(fyne.NewSize(400, 350))
+
+ closeOnEscape(win, scopeDialog.Dismiss)
+ scopeDialog.Show()
+}
diff --git a/internal/ui/settings.go b/internal/ui/settings.go
new file mode 100644
index 0000000..1565066
--- /dev/null
+++ b/internal/ui/settings.go
@@ -0,0 +1,401 @@
+package ui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/cert"
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+)
+
+var settingsWin fyne.Window
+
+func showSettingsDialog(fyneApp fyne.App, parentWin fyne.Window, bus *events.Bus, keybinds *Keybinds) {
+ if settingsWin != nil {
+ settingsWin.RequestFocus()
+ return
+ }
+
+ tabs := container.NewAppTabs(
+ container.NewTabItem("Certificate", buildCertificateTab(parentWin)),
+ container.NewTabItem("Proxy", buildProxyTab(fyneApp, bus)),
+ container.NewTabItem("Browser", buildBrowserTab(fyneApp, parentWin)),
+ container.NewTabItem("Appearance & Shortcuts", buildAppearanceTab(fyneApp, keybinds)),
+ )
+ tabs.SetTabLocation(container.TabLocationTop)
+
+ settingsWin = fyneApp.NewWindow("Settings")
+ settingsWin.SetContent(container.NewPadded(tabs))
+ settingsWin.Resize(fyne.NewSize(480, 420))
+ settingsWin.SetOnClosed(func() { settingsWin = nil })
+ closeOnEscape(settingsWin, settingsWin.Close)
+ settingsWin.Show()
+}
+
+func buildCertificateTab(parentWin fyne.Window) fyne.CanvasObject {
+ statusLabel := widget.NewLabel("")
+ statusLabel.Wrapping = fyne.TextWrapBreak
+
+ installBtn := widget.NewButton("Install CA Certificate", func() {
+ certAuthority, err := cert.Load()
+ if err != nil {
+ statusLabel.SetText("Error loading CA: " + err.Error())
+ logger.Error("settings: load CA: %v", err)
+ return
+ }
+ msg, err := certAuthority.InstallCA()
+ if err != nil {
+ statusLabel.SetText("Installation failed:\n" + err.Error())
+ logger.Error("settings: install CA: %v", err)
+ return
+ }
+ statusLabel.SetText(msg)
+ })
+ installBtn.Importance = widget.HighImportance
+
+ exportBtn := widget.NewButton("Export Certificate (PEM)", func() {
+ certAuthority, err := cert.Load()
+ if err != nil {
+ statusLabel.SetText("Error loading CA: " + err.Error())
+ logger.Error("settings: load CA for export: %v", err)
+ return
+ }
+ pemBytes, err := certAuthority.CertPEM()
+ if err != nil {
+ statusLabel.SetText("Error reading certificate: " + err.Error())
+ logger.Error("settings: cert PEM: %v", err)
+ return
+ }
+ saveDialog := dialog.NewFileSave(func(writeCloser fyne.URIWriteCloser, saveErr error) {
+ if saveErr != nil || writeCloser == nil {
+ return
+ }
+ defer writeCloser.Close()
+ if _, writeErr := writeCloser.Write(pemBytes); writeErr != nil {
+ statusLabel.SetText("Export failed: " + writeErr.Error())
+ logger.Error("settings: export cert: %v", writeErr)
+ return
+ }
+ statusLabel.SetText("Certificate exported successfully.")
+ }, parentWin)
+ saveDialog.SetFileName("shiv-ca.pem")
+ saveDialog.Show()
+ })
+
+ return container.NewVBox(
+ newBoldLabel("CA Certificate"),
+ widget.NewLabel("Install the Shiv CA into your system trust store so browsers accept intercepted traffic."),
+ installBtn,
+ widget.NewSeparator(),
+ widget.NewLabel("Export the CA certificate to import manually into a browser or device."),
+ exportBtn,
+ statusLabel,
+ )
+}
+
+func buildProxyTab(fyneApp fyne.App, bus *events.Bus) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+
+ hostEntry := widget.NewEntry()
+ hostEntry.SetText(prefs.StringWithFallback(prefKeyProxyHost, defaultProxyHost))
+ hostEntry.SetPlaceHolder("127.0.0.1")
+
+ portEntry := widget.NewEntry()
+ portEntry.SetText(strconv.Itoa(prefs.IntWithFallback(prefKeyProxyPort, defaultProxyPort)))
+ portEntry.SetPlaceHolder("9090")
+
+ enabledCheck := widget.NewCheck("Proxy enabled", nil)
+ enabledCheck.Checked = prefs.BoolWithFallback(prefKeyProxyEnabled, defaultProxyEnabled)
+
+ proxyStatus := widget.NewLabel("")
+ proxyStatus.Wrapping = fyne.TextWrapBreak
+
+ saveBtn := widget.NewButton("Save & Restart Proxy", func() {
+ proxyHost := hostEntry.Text
+ proxyPort, err := strconv.Atoi(portEntry.Text)
+ if err != nil || proxyPort < 1 || proxyPort > 65535 {
+ proxyStatus.SetText("Invalid port number.")
+ return
+ }
+ prefs.SetString(prefKeyProxyHost, proxyHost)
+ prefs.SetInt(prefKeyProxyPort, proxyPort)
+ prefs.SetBool(prefKeyProxyEnabled, enabledCheck.Checked)
+
+ if enabledCheck.Checked {
+ addr := fmt.Sprintf("%s:%d", proxyHost, proxyPort)
+ bus.EmitProxyRestart(events.ProxyRestartEvent{Addr: addr})
+ proxyStatus.SetText(fmt.Sprintf("Proxy restarted on %s:%d", proxyHost, proxyPort))
+ proxyRunningBinding.Set(true)
+ proxyStatusBinding.Set(fmt.Sprintf("Proxy: running on %s:%d", proxyHost, proxyPort))
+ } else {
+ bus.EmitProxyStop(events.ProxyStopEvent{})
+ proxyStatus.SetText("Proxy stopped.")
+ proxyRunningBinding.Set(false)
+ proxyStatusBinding.Set("Proxy: stopped")
+ }
+ })
+ saveBtn.Importance = widget.HighImportance
+
+ return container.NewVBox(
+ newBoldLabel("Proxy"),
+ widget.NewForm(
+ widget.NewFormItem("Host", hostEntry),
+ widget.NewFormItem("Port", portEntry),
+ ),
+ enabledCheck,
+ saveBtn,
+ proxyStatus,
+ )
+}
+
+// loadThemeByName loads a theme by name, checking embedded themes first then user themes on disk.
+func loadThemeByName(name, themesDir string) (*LoadedTheme, error) {
+ for _, embedded := range ScanEmbeddedThemes() {
+ if embedded == name {
+ return LoadEmbeddedTheme(name)
+ }
+ }
+ return LoadTheme(filepath.Join(themesDir, name+".toml"))
+}
+
+func buildAppearanceTab(fyneApp fyne.App, keybinds *Keybinds) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+ themesDir := ThemesDir(fyneApp.Storage().RootURI().Path())
+
+ themeStatus := widget.NewLabel("")
+ themeStatus.Importance = widget.LowImportance
+
+ themeSelect := widget.NewSelect(ScanAllThemes(themesDir), nil)
+ if saved := prefs.String(prefKeyUserTheme); saved != "" {
+ themeSelect.SetSelected(saved)
+ } else {
+ themeSelect.SetSelected(ThemeDefaultOption)
+ }
+
+ variantSelect := widget.NewSelect([]string{"Dark", "Light"}, func(selected string) {
+ prefs.SetBool(prefKeyDarkTheme, selected == "Dark")
+ applyTheme(fyneApp)
+ })
+ if prefs.BoolWithFallback(prefKeyDarkTheme, defaultDarkTheme) {
+ variantSelect.SetSelected("Dark")
+ } else {
+ variantSelect.SetSelected("Light")
+ }
+ if lt := loadActiveTheme(fyneApp); lt != nil && !lt.HasBoth {
+ variantSelect.Hide()
+ }
+
+ applyBtn := widget.NewButton("Apply Theme", func() {
+ selected := themeSelect.Selected
+ if selected != ThemeDefaultOption {
+ if _, err := loadThemeByName(selected, themesDir); err != nil {
+ themeStatus.SetText("Invalid theme: " + err.Error())
+ logger.Error("settings: load theme %s: %v", selected, err)
+ return
+ }
+ prefs.SetString(prefKeyUserTheme, selected)
+ } else {
+ prefs.SetString(prefKeyUserTheme, "")
+ }
+ applyTheme(fyneApp)
+ if lt := loadActiveTheme(fyneApp); lt != nil && !lt.HasBoth {
+ variantSelect.Hide()
+ } else {
+ variantSelect.Show()
+ }
+ themeStatus.SetText("Applied. Restart to update the dark/light toggle visibility.")
+ })
+
+ addThemeBtn := widget.NewButtonWithIcon("Add Theme", AppIcon("save"), func() {
+ d := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) {
+ if err != nil || rc == nil {
+ return
+ }
+ defer rc.Close()
+ data, err := io.ReadAll(rc)
+ if err != nil {
+ themeStatus.SetText("Failed to read file: " + err.Error())
+ return
+ }
+ dest := filepath.Join(themesDir, filepath.Base(rc.URI().Path()))
+ if err := os.WriteFile(dest, data, 0644); err != nil {
+ themeStatus.SetText("Failed to save theme: " + err.Error())
+ return
+ }
+ themeStatus.SetText("Theme added: " + filepath.Base(dest))
+ themeSelect.Options = ScanAllThemes(themesDir)
+ themeSelect.Refresh()
+ }, settingsWin)
+ d.SetFilter(storage.NewExtensionFileFilter([]string{".toml"}))
+ d.Show()
+ })
+
+ themeDirLabel := widget.NewLabel("User themes: " + themesDir)
+ themeDirLabel.Wrapping = fyne.TextWrapBreak
+ themeDirLabel.Importance = widget.LowImportance
+
+ sendKeyEntry := widget.NewEntry()
+ sendKeyEntry.SetText(prefs.StringWithFallback(prefKeySendRequest, string(defaultKeySendRequest)))
+
+ closeTabKeyEntry := widget.NewEntry()
+ closeTabKeyEntry.SetText(prefs.StringWithFallback(prefKeyCloseTab, string(defaultKeyCloseTab)))
+
+ toRepeaterKeyEntry := widget.NewEntry()
+ toRepeaterKeyEntry.SetText(prefs.StringWithFallback(prefKeyToRepeater, string(defaultKeyToRepeater)))
+
+ for _, e := range []*widget.Entry{sendKeyEntry, closeTabKeyEntry, toRepeaterKeyEntry} {
+ entry := e
+ entry.OnChanged = func(text string) {
+ if len(text) > 0 {
+ entry.SetText(strings.ToUpper(string([]rune(text)[0])))
+ }
+ }
+ }
+
+ shortcutStatus := widget.NewLabel("")
+
+ saveShortcutsBtn := widget.NewButton("Save Shortcuts", func() {
+ keybinds.KeySendRequest = fyne.KeyName(strings.ToUpper(sendKeyEntry.Text))
+ keybinds.KeyCloseTab = fyne.KeyName(strings.ToUpper(closeTabKeyEntry.Text))
+ keybinds.KeyToRepeater = fyne.KeyName(strings.ToUpper(toRepeaterKeyEntry.Text))
+ prefs.SetString(prefKeySendRequest, strings.ToUpper(sendKeyEntry.Text))
+ prefs.SetString(prefKeyCloseTab, strings.ToUpper(closeTabKeyEntry.Text))
+ prefs.SetString(prefKeyToRepeater, strings.ToUpper(toRepeaterKeyEntry.Text))
+ keybinds.Update()
+ shortcutStatus.SetText("Shortcuts updated.")
+ })
+
+ return container.NewVBox(
+ newBoldLabel("Appearance"),
+ widget.NewForm(
+ widget.NewFormItem("Variant", variantSelect),
+ widget.NewFormItem("Theme", themeSelect),
+ ),
+ applyBtn,
+ addThemeBtn,
+ themeStatus,
+ themeDirLabel,
+ widget.NewSeparator(),
+ newBoldLabel("Shortcuts"),
+ widget.NewLabel("Single letter keys always, Ctrl modifier always applied (eg. Send is Ctrl + s)."),
+ widget.NewForm(
+ widget.NewFormItem("Send Request", sendKeyEntry),
+ widget.NewFormItem("Close Tab", closeTabKeyEntry),
+ widget.NewFormItem("Send to Repeater", toRepeaterKeyEntry),
+ ),
+ saveShortcutsBtn,
+ shortcutStatus,
+ )
+}
+
+func buildBrowserTab(fyneApp fyne.App, win fyne.Window) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+ statusLabel := widget.NewLabel("")
+ statusLabel.Wrapping = fyne.TextWrapBreak
+
+ browsers := DetectBrowsers()
+ names := make([]string, len(browsers))
+ for i, b := range browsers {
+ names[i] = b.Name
+ }
+
+ var browserSelect *widget.Select
+ if len(browsers) == 0 {
+ browserSelect = widget.NewSelect([]string{"No browsers detected"}, nil)
+ browserSelect.SetSelected("No browsers detected")
+ browserSelect.Disable()
+ } else {
+ browserSelect = widget.NewSelect(names, nil)
+ savedPath := prefs.String(prefKeyDefaultBrowser)
+ for _, b := range browsers {
+ if b.Path == savedPath {
+ browserSelect.SetSelected(b.Name)
+ break
+ }
+ }
+ if browserSelect.Selected == "" {
+ browserSelect.SetSelected(names[0])
+ }
+ }
+
+ rescanBtn := widget.NewButton("Rescan", func() {
+ fresh := DetectBrowsers()
+ freshNames := make([]string, len(fresh))
+ for i, b := range fresh {
+ freshNames[i] = b.Name
+ }
+ browsers = fresh
+ names = freshNames
+ if len(freshNames) == 0 {
+ browserSelect.Options = []string{"No browsers detected"}
+ browserSelect.SetSelected("No browsers detected")
+ browserSelect.Disable()
+ statusLabel.SetText("No supported browsers found.")
+ } else {
+ browserSelect.Enable()
+ browserSelect.Options = freshNames
+ browserSelect.SetSelected(freshNames[0])
+ statusLabel.SetText(fmt.Sprintf("Found %d browser(s).", len(freshNames)))
+ }
+ browserSelect.Refresh()
+ })
+
+ saveBtn := widget.NewButton("Save Default", func() {
+ if len(browsers) == 0 {
+ return
+ }
+ for _, b := range browsers {
+ if b.Name == browserSelect.Selected {
+ prefs.SetString(prefKeyDefaultBrowser, b.Path)
+ statusLabel.SetText("Default browser saved: " + b.Name)
+ return
+ }
+ }
+ })
+ saveBtn.Importance = widget.HighImportance
+
+ clearProfileBtn := widget.NewButton("Clear Browser Profile", func() {
+ for _, b := range browsers {
+ if b.Name == browserSelect.Selected {
+ if err := ClearBrowserProfile(fyneApp, b.Path); err != nil {
+ statusLabel.SetText("Error: " + err.Error())
+ } else {
+ statusLabel.SetText("Profile cleared for: " + b.Name)
+ }
+ return
+ }
+ }
+ })
+
+ return container.NewVBox(
+ newBoldLabel("Browser"),
+ widget.NewLabel("Launch a browser pre-configured to route traffic through Shiv's proxy with an isolated profile."),
+ widget.NewForm(
+ widget.NewFormItem("Default browser", container.NewBorder(nil, nil, nil, rescanBtn, browserSelect)),
+ ),
+ container.NewHBox(saveBtn, clearProfileBtn),
+ statusLabel,
+ )
+}
+
+func newBrowserPickerContent(names []string, onChanged func(string)) fyne.CanvasObject {
+ sel := widget.NewSelect(names, onChanged)
+ if len(names) > 0 {
+ sel.SetSelected(names[0])
+ }
+ return container.NewVBox(
+ widget.NewLabel("Multiple browsers detected. Choose one to set as default:"),
+ sel,
+ )
+}
diff --git a/internal/ui/theme.go b/internal/ui/theme.go
new file mode 100644
index 0000000..591a102
--- /dev/null
+++ b/internal/ui/theme.go
@@ -0,0 +1,373 @@
+package ui
+
+import (
+ "fmt"
+ "image/color"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/theme"
+ "github.com/BurntSushi/toml"
+ "github.com/shiv/assets"
+)
+
+// ── Theme file format ─────────────────────────────────────────────────────────
+
+type themePalette struct {
+ Background string `toml:"background"`
+ Button string `toml:"button"`
+ DisabledButton string `toml:"disabled_button"`
+ Disabled string `toml:"disabled"`
+ Error string `toml:"error"`
+ Focus string `toml:"focus"`
+ Foreground string `toml:"foreground"`
+ HeaderBackground string `toml:"header_background"`
+ Hover string `toml:"hover"`
+ Hyperlink string `toml:"hyperlink"`
+ InputBackground string `toml:"input_background"`
+ InputBorder string `toml:"input_border"`
+ MenuBackground string `toml:"menu_background"`
+ OverlayBackground string `toml:"overlay_background"`
+ PlaceHolder string `toml:"placeholder"`
+ Pressed string `toml:"pressed"`
+ Primary string `toml:"primary"`
+ ScrollBar string `toml:"scrollbar"`
+ Selection string `toml:"selection"`
+ Separator string `toml:"separator"`
+ Shadow string `toml:"shadow"`
+ Success string `toml:"success"`
+ Warning string `toml:"warning"`
+}
+
+type themeFile struct {
+ Dark *themePalette `toml:"dark"`
+ Light *themePalette `toml:"light"`
+}
+
+// LoadedTheme is a parsed, validated theme ready for use.
+type LoadedTheme struct {
+ Name string
+ Dark map[fyne.ThemeColorName]color.Color
+ Light map[fyne.ThemeColorName]color.Color
+ HasBoth bool
+ Embedded bool
+}
+
+// ── Color name mapping ────────────────────────────────────────────────────────
+
+var paletteKeys = map[fyne.ThemeColorName]string{
+ theme.ColorNameBackground: "background",
+ theme.ColorNameButton: "button",
+ theme.ColorNameDisabledButton: "disabled_button",
+ theme.ColorNameDisabled: "disabled",
+ theme.ColorNameError: "error",
+ theme.ColorNameFocus: "focus",
+ theme.ColorNameForeground: "foreground",
+ theme.ColorNameHeaderBackground: "header_background",
+ theme.ColorNameHover: "hover",
+ theme.ColorNameHyperlink: "hyperlink",
+ theme.ColorNameInputBackground: "input_background",
+ theme.ColorNameInputBorder: "input_border",
+ theme.ColorNameMenuBackground: "menu_background",
+ theme.ColorNameOverlayBackground: "overlay_background",
+ theme.ColorNamePlaceHolder: "placeholder",
+ theme.ColorNamePressed: "pressed",
+ theme.ColorNamePrimary: "primary",
+ theme.ColorNameScrollBar: "scrollbar",
+ theme.ColorNameSelection: "selection",
+ theme.ColorNameSeparator: "separator",
+ theme.ColorNameShadow: "shadow",
+ theme.ColorNameSuccess: "success",
+ theme.ColorNameWarning: "warning",
+}
+
+var paletteKeysReverse map[string]fyne.ThemeColorName
+
+func init() {
+ paletteKeysReverse = make(map[string]fyne.ThemeColorName, len(paletteKeys))
+ for k, v := range paletteKeys {
+ paletteKeysReverse[v] = k
+ }
+}
+
+// ── Theme scanning ────────────────────────────────────────────────────────────
+
+const ThemeDefaultOption = "Default"
+
+// ThemesDir returns the user themes directory, creating it if needed.
+func ThemesDir(storageRoot string) string {
+ dir := filepath.Join(storageRoot, "themes")
+ os.MkdirAll(dir, 0755)
+ return dir
+}
+
+// ScanEmbeddedThemes returns names of all embedded themes (without extension).
+func ScanEmbeddedThemes() []string {
+ entries, err := fs.ReadDir(assets.Themes, "themes")
+ if err != nil {
+ return nil
+ }
+ var names []string
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".toml") {
+ names = append(names, strings.TrimSuffix(e.Name(), ".toml"))
+ }
+ }
+ return names
+}
+
+// ScanUserThemes returns names of all user .toml themes in dir (without extension).
+func ScanUserThemes(dir string) []string {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var names []string
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".toml") {
+ names = append(names, strings.TrimSuffix(e.Name(), ".toml"))
+ }
+ }
+ return names
+}
+
+// ScanAllThemes returns the full theme list for the dropdown:
+// [Default, ...embedded..., ...user (skipping duplicates of embedded)...]
+func ScanAllThemes(userThemesDir string) []string {
+ embedded := ScanEmbeddedThemes()
+ embeddedSet := make(map[string]bool, len(embedded))
+ for _, n := range embedded {
+ embeddedSet[n] = true
+ }
+ options := make([]string, 0, 1+len(embedded)+8)
+ options = append(options, ThemeDefaultOption)
+ options = append(options, embedded...)
+ for _, n := range ScanUserThemes(userThemesDir) {
+ if !embeddedSet[n] {
+ options = append(options, n)
+ }
+ }
+ return options
+}
+
+// ── Theme loading ─────────────────────────────────────────────────────────────
+
+// LoadEmbeddedTheme loads a theme by name from the embedded assets.
+func LoadEmbeddedTheme(name string) (*LoadedTheme, error) {
+ data, err := assets.Themes.ReadFile("themes/" + name + ".toml")
+ if err != nil {
+ return nil, fmt.Errorf("embedded theme %q not found: %w", name, err)
+ }
+ lt, err := parseThemeBytes(data, name)
+ if err != nil {
+ return nil, err
+ }
+ lt.Embedded = true
+ return lt, nil
+}
+
+// LoadTheme loads a theme from a file path on disk.
+func LoadTheme(path string) (*LoadedTheme, error) {
+ name := strings.TrimSuffix(filepath.Base(path), ".toml")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read theme: %w", err)
+ }
+ return parseThemeBytes(data, name)
+}
+
+func parseThemeBytes(data []byte, name string) (*LoadedTheme, error) {
+ var tf themeFile
+ if err := toml.Unmarshal(data, &tf); err != nil {
+ return nil, fmt.Errorf("parse theme: %w", err)
+ }
+ if tf.Dark == nil && tf.Light == nil {
+ return nil, fmt.Errorf("theme must have at least a [dark] or [light] section")
+ }
+ lt := &LoadedTheme{
+ Name: name,
+ HasBoth: tf.Dark != nil && tf.Light != nil,
+ }
+ if tf.Dark != nil {
+ m, err := parsePalette(tf.Dark)
+ if err != nil {
+ return nil, fmt.Errorf("[dark]: %w", err)
+ }
+ lt.Dark = m
+ }
+ if tf.Light != nil {
+ m, err := parsePalette(tf.Light)
+ if err != nil {
+ return nil, fmt.Errorf("[light]: %w", err)
+ }
+ lt.Light = m
+ }
+ if lt.Dark == nil {
+ lt.Dark = lt.Light
+ }
+ if lt.Light == nil {
+ lt.Light = lt.Dark
+ }
+ return lt, nil
+}
+
+func parsePalette(p *themePalette) (map[fyne.ThemeColorName]color.Color, error) {
+ raw := map[string]string{
+ "background": p.Background,
+ "button": p.Button,
+ "disabled_button": p.DisabledButton,
+ "disabled": p.Disabled,
+ "error": p.Error,
+ "focus": p.Focus,
+ "foreground": p.Foreground,
+ "header_background": p.HeaderBackground,
+ "hover": p.Hover,
+ "hyperlink": p.Hyperlink,
+ "input_background": p.InputBackground,
+ "input_border": p.InputBorder,
+ "menu_background": p.MenuBackground,
+ "overlay_background": p.OverlayBackground,
+ "placeholder": p.PlaceHolder,
+ "pressed": p.Pressed,
+ "primary": p.Primary,
+ "scrollbar": p.ScrollBar,
+ "selection": p.Selection,
+ "separator": p.Separator,
+ "shadow": p.Shadow,
+ "success": p.Success,
+ "warning": p.Warning,
+ }
+ m := make(map[fyne.ThemeColorName]color.Color, len(raw))
+ for key, val := range raw {
+ if val == "" {
+ continue
+ }
+ c, err := parseHex(val)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", key, err)
+ }
+ if colorName, ok := paletteKeysReverse[key]; ok {
+ m[colorName] = c
+ }
+ }
+ return m, nil
+}
+
+func parseHex(h string) (color.NRGBA, error) {
+ h = strings.TrimPrefix(h, "#")
+ if len(h) != 6 {
+ return color.NRGBA{}, fmt.Errorf("invalid hex %q (expected #rrggbb)", h)
+ }
+ var r, g, b uint8
+ if _, err := fmt.Sscanf(h, "%02x%02x%02x", &r, &g, &b); err != nil {
+ return color.NRGBA{}, fmt.Errorf("invalid hex %q: %w", h, err)
+ }
+ return color.NRGBA{R: r, G: g, B: b, A: 0xff}, nil
+}
+
+// ── Fyne theme implementation ─────────────────────────────────────────────────
+
+type vagueTheme struct {
+ dark bool
+ loaded *LoadedTheme
+}
+
+func NewVagueThemeWithLoaded(dark bool, lt *LoadedTheme) fyne.Theme {
+ return &vagueTheme{dark: dark, loaded: lt}
+}
+
+func (t *vagueTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
+ if t.loaded != nil {
+ var palette map[fyne.ThemeColorName]color.Color
+ if t.dark {
+ palette = t.loaded.Dark
+ } else {
+ palette = t.loaded.Light
+ }
+ if c, ok := palette[name]; ok {
+ return c
+ }
+ }
+ if t.dark {
+ return theme.DefaultTheme().Color(name, theme.VariantDark)
+ }
+ return theme.DefaultTheme().Color(name, theme.VariantLight)
+}
+
+func (t *vagueTheme) Font(style fyne.TextStyle) fyne.Resource {
+ switch {
+ case style.Bold && style.Italic:
+ return fyne.NewStaticResource("JetBrainsMono-BoldItalic.ttf", assets.FontJetBrainsMonoBold)
+ case style.Bold:
+ return fyne.NewStaticResource("JetBrainsMono-Bold.ttf", assets.FontJetBrainsMonoBold)
+ case style.Italic:
+ return fyne.NewStaticResource("JetBrainsMono-Italic.ttf", assets.FontJetBrainsMonoItalic)
+ default:
+ return fyne.NewStaticResource("JetBrainsMono-Regular.ttf", assets.FontJetBrainsMonoRegular)
+ }
+}
+
+func (t *vagueTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
+ return theme.DefaultTheme().Icon(name)
+}
+
+func (t *vagueTheme) Size(name fyne.ThemeSizeName) float32 {
+ if name == theme.SizeNameInlineIcon {
+ return 28
+ }
+ if name == theme.SizeNameText {
+ return 13
+ }
+ return theme.DefaultTheme().Size(name)
+}
+
+// ── Icons ─────────────────────────────────────────────────────────────────────
+
+func AppIcon(name string) fyne.Resource {
+ switch name {
+ case "delete":
+ return fyne.NewStaticResource("delete.png", assets.IconDelete)
+ case "save":
+ return fyne.NewStaticResource("save.png", assets.IconSave)
+ case "history":
+ return fyne.NewStaticResource("history.png", assets.IconHistory)
+ case "repeater":
+ return fyne.NewStaticResource("boomerang.png", assets.IconRepeater)
+ case "inspector":
+ return fyne.NewStaticResource("inspector.png", assets.IconInspector)
+ case "intruder":
+ return fyne.NewStaticResource("intruder.png", assets.IconIntruder)
+ case "intercept":
+ return fyne.NewStaticResource("intercept.png", assets.IconIntercept)
+ case "loot":
+ return fyne.NewStaticResource("loot.png", assets.IconLoot)
+ case "project":
+ return fyne.NewStaticResource("project.png", assets.IconProject)
+ case "scope":
+ return fyne.NewStaticResource("scope.png", assets.IconScope)
+ case "off-button":
+ return fyne.NewStaticResource("off-button.png", assets.IconOffButton)
+ case "on-button":
+ return fyne.NewStaticResource("on-button.png", assets.IconOnButton)
+ case "settings":
+ return fyne.NewStaticResource("settings.png", assets.IconSettings)
+ case "toolbox":
+ return fyne.NewStaticResource("toolbox.png", assets.IconToolbox)
+ case "plugin":
+ return fyne.NewStaticResource("plugin.png", assets.IconPlugin)
+ case "web":
+ return fyne.NewStaticResource("web.png", assets.IconWeb)
+ case "web1":
+ return fyne.NewStaticResource("web1.png", assets.IconWeb1)
+ case "document":
+ return fyne.NewStaticResource("document.png", assets.IconDocument)
+ case "folder":
+ return fyne.NewStaticResource("folder.png", assets.IconFolder)
+ case "media":
+ return fyne.NewStaticResource("media.png", assets.IconMedia)
+ }
+ return theme.DefaultTheme().Icon(theme.IconNameQuestion)
+}
diff --git a/internal/ui/websocket.go b/internal/ui/websocket.go
new file mode 100644
index 0000000..9251cac
--- /dev/null
+++ b/internal/ui/websocket.go
@@ -0,0 +1,379 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+ "github.com/shiv/internal/store"
+ "github.com/shiv/internal/ui/widgets"
+)
+
+var wsWin fyne.Window
+
+func showWebSocketWindow(fyneApp fyne.App, projectStore *store.Store, parentWin fyne.Window, repeater *repeaterTab) {
+ if wsWin != nil {
+ wsWin.RequestFocus()
+ return
+ }
+ wsWin = fyneApp.NewWindow("WebSockets")
+ wsWin.Resize(fyne.NewSize(1100, 650))
+ wsWin.SetOnClosed(func() { wsWin = nil })
+ closeOnEscape(wsWin, wsWin.Close)
+ wsWin.SetContent(buildWebSocketContent(projectStore, wsWin, repeater))
+ wsWin.Show()
+}
+
+func filterConnections(all []store.WebSocketConnection, query string) []store.WebSocketConnection {
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]store.WebSocketConnection, len(all))
+ copy(out, all)
+ return out
+ }
+ terms := strings.Fields(q)
+ var out []store.WebSocketConnection
+ for _, c := range all {
+ s := strings.ToLower(c.Host + " " + c.URL)
+ ok := true
+ for _, t := range terms {
+ if !strings.Contains(s, t) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func filterFrames(all []store.WebSocketFrame, query string) []store.WebSocketFrame {
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]store.WebSocketFrame, len(all))
+ copy(out, all)
+ return out
+ }
+ terms := strings.Fields(q)
+ var out []store.WebSocketFrame
+ for _, f := range all {
+ s := strings.ToLower(string(f.Payload) + " " + opcodeLabel(f.Opcode))
+ ok := true
+ for _, t := range terms {
+ if !strings.Contains(s, t) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+func buildConnTable(win fyne.Window, filteredConns *[]store.WebSocketConnection) (*widgets.DataTable, fyne.CanvasObject) {
+ connColumns := []widgets.DataTableColumn{
+ {Header: "ID", Width: 50},
+ {Header: "Host", Width: 220},
+ {Header: "URL", Width: 380},
+ {Header: "TLS", Width: 45},
+ {Header: "Frames", Width: 70},
+ {Header: "Time", Width: 180},
+ }
+ connTable := widgets.NewDataTable()
+ connTable.SetWindow(win)
+ connTable.Columns = connColumns
+ connTable.RowCount = func() int { return len(*filteredConns) }
+ connTable.RowID = func(row int) int64 {
+ if row >= len(*filteredConns) {
+ return 0
+ }
+ return int64((*filteredConns)[row].ID)
+ }
+ connTable.CellValue = func(row, col int) string {
+ if row >= len(*filteredConns) {
+ return ""
+ }
+ c := (*filteredConns)[row]
+ switch col {
+ case 0:
+ return fmt.Sprintf("%d", c.ID)
+ case 1:
+ return c.Host
+ case 2:
+ return c.URL
+ case 3:
+ if c.TLS {
+ return "✓"
+ }
+ return ""
+ case 4:
+ return fmt.Sprintf("%d", c.FrameCount)
+ case 5:
+ return c.Timestamp.Local().Format("2006-01-02 15:04:05")
+ }
+ return ""
+ }
+ return connTable, connTable.Build()
+}
+
+func buildFrameTable(win fyne.Window, filteredFrames *[]store.WebSocketFrame) (*widgets.DataTable, fyne.CanvasObject) {
+ frameColumns := []widgets.DataTableColumn{
+ {Header: "Dir", Width: 90},
+ {Header: "Type", Width: 70},
+ {Header: "Length", Width: 80},
+ {Header: "Time", Width: 150},
+ }
+ frameTable := widgets.NewDataTable()
+ frameTable.SetWindow(win)
+ frameTable.Columns = frameColumns
+ frameTable.RowCount = func() int { return len(*filteredFrames) }
+ frameTable.RowID = func(row int) int64 {
+ if row >= len(*filteredFrames) {
+ return 0
+ }
+ return int64((*filteredFrames)[row].ID)
+ }
+ frameTable.CellValue = func(row, col int) string {
+ if row >= len(*filteredFrames) {
+ return ""
+ }
+ f := (*filteredFrames)[row]
+ switch col {
+ case 0:
+ if f.Direction == events.WebSocketClient {
+ return "→ Client"
+ }
+ return "← Server"
+ case 1:
+ return opcodeLabel(f.Opcode)
+ case 2:
+ return fmt.Sprintf("%db", len(f.Payload))
+ case 3:
+ return f.Timestamp.Local().Format("15:04:05.000")
+ }
+ return ""
+ }
+ frameTable.CellStyle = func(row, col int) widget.Importance {
+ if col != 0 || row >= len(*filteredFrames) {
+ return widget.MediumImportance
+ }
+ if (*filteredFrames)[row].Direction == events.WebSocketClient {
+ return widget.HighImportance
+ }
+ return widget.SuccessImportance
+ }
+ return frameTable, frameTable.Build()
+}
+
+func buildFrameDetail(win fyne.Window, filteredFrames *[]store.WebSocketFrame, frameTable *widgets.DataTable) (*widgets.TextView, fyne.CanvasObject) {
+ payloadView := widgets.NewTextView()
+ payloadView.SetWindow(win)
+ payloadView.SetPlaceHolder("Select a frame to view payload...")
+
+ frameTable.OnSelect = func(row int) {
+ if row >= len(*filteredFrames) {
+ return
+ }
+ payload := (*filteredFrames)[row].Payload
+ if len(payload) == 0 {
+ payloadView.SetText("(empty)")
+ } else {
+ payloadView.SetText(string(payload))
+ }
+ }
+
+ payloadPane := container.NewBorder(
+ newBoldLabel("Payload"),
+ nil, nil, nil,
+ payloadView.Build(),
+ )
+ return payloadView, payloadPane
+}
+
+func buildWebSocketContent(projectStore *store.Store, win fyne.Window, repeater *repeaterTab) fyne.CanvasObject {
+ var allConns []store.WebSocketConnection
+ var filteredConns []store.WebSocketConnection
+ var allFrames []store.WebSocketFrame
+ var filteredFrames []store.WebSocketFrame
+ var selectedConnID uint64
+
+ connFilterEntry := widget.NewEntry()
+ connFilterEntry.SetPlaceHolder("Filter — host, url...")
+ frameFilterEntry := widget.NewEntry()
+ frameFilterEntry.SetPlaceHolder("Filter frames — payload, type...")
+
+ refilterConns := func() { filteredConns = filterConnections(allConns, connFilterEntry.Text) }
+ refilterFrames := func() { filteredFrames = filterFrames(allFrames, frameFilterEntry.Text) }
+
+ connTable, connTableObj := buildConnTable(win, &filteredConns)
+ frameTable, frameTableObj := buildFrameTable(win, &filteredFrames)
+ payloadView, payloadPane := buildFrameDetail(win, &filteredFrames, frameTable)
+
+ loadFrames := func(connID uint64) {
+ go func() {
+ loaded, err := projectStore.FramesForConnection(connID)
+ if err != nil {
+ logger.Error("ws ui: load frames %d: %v", connID, err)
+ return
+ }
+ fyne.Do(func() {
+ allFrames = loaded
+ refilterFrames()
+ frameTable.Refresh()
+ payloadView.SetText("")
+ })
+ }()
+ }
+
+ connTable.OnSelect = func(row int) {
+ if row >= len(filteredConns) {
+ return
+ }
+ selectedConnID = filteredConns[row].ID
+ loadFrames(selectedConnID)
+ }
+
+ connTable.MenuItems = func(row int) []widgets.ContextMenuItem {
+ if row >= len(filteredConns) {
+ return nil
+ }
+ c := filteredConns[row]
+ return []widgets.ContextMenuItem{
+ {Label: "Send to Repeater", Action: func() {
+ if repeater != nil {
+ repeater.AddWSTab(c.Host, c.URL, c.TLS)
+ }
+ }},
+ {Label: "Copy URL", Action: func() {
+ fyne.CurrentApp().Clipboard().SetContent(c.URL)
+ }},
+ }
+ }
+
+ connFilterEntry.OnChanged = func(q string) {
+ filteredConns = filterConnections(allConns, q)
+ connTable.Refresh()
+ }
+ frameFilterEntry.OnChanged = func(q string) {
+ filteredFrames = filterFrames(allFrames, q)
+ frameTable.Refresh()
+ }
+
+ go func() {
+ for conn := range projectStore.WebSocketConnections {
+ c := conn
+ fyne.Do(func() {
+ for _, existing := range allConns {
+ if existing.ID == c.ID {
+ return
+ }
+ }
+ allConns = append([]store.WebSocketConnection{c}, allConns...)
+ refilterConns()
+ connTable.Refresh()
+ })
+ }
+ }()
+
+ go func() {
+ for frame := range projectStore.WebSocketFrames {
+ f := frame
+ fyne.Do(func() {
+ for i := range allConns {
+ if allConns[i].ID == f.ConnectionID {
+ allConns[i].FrameCount++
+ break
+ }
+ }
+ refilterConns()
+ connTable.Refresh()
+ if f.ConnectionID != selectedConnID {
+ return
+ }
+ go func() {
+ loaded, err := projectStore.FramesForConnection(f.ConnectionID)
+ if err != nil {
+ logger.Error("ws ui: reload frames %d: %v", f.ConnectionID, err)
+ return
+ }
+ fyne.Do(func() {
+ allFrames = loaded
+ refilterFrames()
+ frameTable.Refresh()
+ })
+ }()
+ })
+ }
+ }()
+
+ refreshBtn := widget.NewButtonWithIcon("Refresh", AppIcon("history"), func() {
+ go func() {
+ loaded, err := projectStore.AllWebSocketConnections()
+ if err != nil {
+ logger.Error("ws ui: load connections: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ allConns = loaded
+ refilterConns()
+ connTable.Refresh()
+ if selectedConnID > 0 {
+ loadFrames(selectedConnID)
+ }
+ })
+ }()
+ })
+
+ go func() {
+ loaded, err := projectStore.AllWebSocketConnections()
+ if err != nil {
+ logger.Error("ws ui: initial load: %v", err)
+ return
+ }
+ fyne.Do(func() {
+ allConns = loaded
+ filteredConns = filterConnections(allConns, "")
+ connTable.Refresh()
+ })
+ }()
+
+ connFilterBar := container.NewBorder(nil, nil, nil, refreshBtn, connFilterEntry)
+ frameFilterBar := container.NewBorder(nil, nil, newBoldLabel("Frames"), nil, frameFilterEntry)
+
+ connPane := container.NewBorder(
+ container.NewVBox(newBoldLabel("Connections"), connFilterBar),
+ nil, nil, nil, connTableObj,
+ )
+ framePane := container.NewBorder(frameFilterBar, nil, nil, nil, frameTableObj)
+
+ bottomSplit := container.NewHSplit(framePane, payloadPane)
+ bottomSplit.SetOffset(0.4)
+ mainSplit := container.NewVSplit(connPane, bottomSplit)
+ mainSplit.SetOffset(0.4)
+ return mainSplit
+}
+
+func opcodeLabel(op events.WebSocketOpcode) string {
+ switch op {
+ case events.WebSocketText:
+ return "Text"
+ case events.WebSocketBinary:
+ return "Binary"
+ case events.WebSocketPing:
+ return "Ping"
+ case events.WebSocketPong:
+ return "Pong"
+ case events.WebSocketClose:
+ return "Close"
+ }
+ return fmt.Sprintf("0x%x", int(op))
+}
diff --git a/internal/ui/widgets/datatable.go b/internal/ui/widgets/datatable.go
new file mode 100644
index 0000000..2cf476d
--- /dev/null
+++ b/internal/ui/widgets/datatable.go
@@ -0,0 +1,876 @@
+package widgets
+
+import (
+ "image/color"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+// DataTableColumn describes a single column header and its initial width.
+type DataTableColumn struct {
+ Header string
+ Width float32
+}
+
+// ContextMenuItem describes a single item in a right-click context menu.
+type ContextMenuItem struct {
+ Label string
+ Action func()
+}
+
+// Layout constants for DataTable. All sizes are in logical pixels.
+const (
+ dtRowH float32 = 32 // height of a data row
+ dtHdrH float32 = 34 // height of the header row
+ dtPadX float32 = 8 // horizontal cell padding
+ dtDivW float32 = 6 // width of a column-divider hit area
+ dtScrollW float32 = 8 // width of the scrollbar track
+
+ // dtNearBottomThreshold is how many rows from the end triggers OnNearBottom.
+ dtNearBottomThreshold = 20
+)
+
+// DataTable is a virtualised, row-selectable table widget with resizable
+// columns, right-click context menus, and a draggable scrollbar thumb.
+//
+// Create with NewDataTable, set all callback fields, call SetWindow, then
+// use the object returned by Build in your layout.
+type DataTable struct {
+ widget.BaseWidget
+
+ // Columns defines the header labels and initial widths.
+ Columns []DataTableColumn
+
+ // Data callbacks — may be updated at runtime; call Refresh after changes.
+ RowCount func() int
+ CellValue func(row, col int) string
+ CellStyle func(row, col int) widget.Importance // nil → MediumImportance
+ RowID func(row int) int64 // stable unique ID per row
+
+ // Interaction callbacks.
+ OnSelect func(row int)
+ MenuItems func(row int) []ContextMenuItem // nil → no context menu
+ OnNearBottom func() // fired when scrolled near the last row
+ RowBgColor func(row int) color.Color // nil → default background; non-nil overrides row background
+
+ colWidths []float32
+ selectedID int64
+ selectedRow int // last-selected row index; used for keyboard navigation
+ hasSelected bool
+ scrollOffset int
+ scrollFrac float32 // sub-row accumulator for smooth trackpad scrolling
+
+ nearBottomFired bool // debounce: only fire once per approach to the bottom
+
+ scrollbarHovered bool
+
+ win fyne.Window
+ rend *dtRenderer
+ inputLayer *dtInputLayer
+}
+
+// NewDataTable allocates and extends a DataTable.
+func NewDataTable() *DataTable {
+ t := &DataTable{}
+ t.ExtendBaseWidget(t)
+ return t
+}
+
+// SetWindow provides the window reference required for context menu anchoring.
+// Must be called before the widget is shown.
+func (t *DataTable) SetWindow(w fyne.Window) { t.win = w }
+
+// Build constructs the complete canvas object to place in a layout.
+// Call once after all callbacks are configured.
+func (t *DataTable) Build() fyne.CanvasObject {
+ t.initColWidths()
+ layer := newDTInputLayer(t)
+ t.inputLayer = layer
+ return container.NewStack(t, layer)
+}
+
+// SetSelected highlights the row identified by id. Pass 0 to clear.
+func (t *DataTable) SetSelected(id int64) {
+ t.selectedID = id
+ t.hasSelected = id != 0
+ t.Refresh()
+}
+
+// ClearSelection removes the current row highlight.
+func (t *DataTable) ClearSelection() {
+ t.selectedID = 0
+ t.hasSelected = false
+ t.Refresh()
+}
+
+// ScrollToRow scrolls the viewport so that row is visible.
+func (t *DataTable) ScrollToRow(row int) {
+ visible := t.visibleSlots()
+ if row < t.scrollOffset {
+ t.scrollOffset = row
+ } else if row >= t.scrollOffset+visible {
+ t.scrollOffset = row - visible + 1
+ }
+ if t.scrollOffset < 0 {
+ t.scrollOffset = 0
+ }
+ t.Refresh()
+}
+
+// Scrolled implements fyne.Scrollable, forwarding wheel events from the
+// input layer overlay.
+func (t *DataTable) Scrolled(ev *fyne.ScrollEvent) {
+ totalRows := t.rowCount()
+ visible := t.visibleSlots()
+ maxOffset := totalRows - visible
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ t.scrollFrac -= ev.Scrolled.DY / dtRowH
+ rows := int(t.scrollFrac)
+ t.scrollFrac -= float32(rows)
+ if rows == 0 {
+ if ev.Scrolled.DY < 0 {
+ rows = 1
+ } else if ev.Scrolled.DY > 0 {
+ rows = -1
+ }
+ }
+ t.scrollOffset += rows
+ if t.scrollOffset < 0 {
+ t.scrollOffset = 0
+ }
+ if t.scrollOffset > maxOffset {
+ t.scrollOffset = maxOffset
+ }
+ t.Refresh()
+
+ // Fire OnNearBottom when the user scrolls within dtNearBottomThreshold rows
+ // of the end. Debounced: only fires once until the user scrolls back up.
+ if t.OnNearBottom != nil && totalRows > 0 {
+ rowsFromBottom := totalRows - (t.scrollOffset + visible)
+ if rowsFromBottom < dtNearBottomThreshold {
+ if !t.nearBottomFired {
+ t.nearBottomFired = true
+ t.OnNearBottom()
+ }
+ } else {
+ // Reset debounce when user scrolls back up far enough.
+ t.nearBottomFired = false
+ }
+ }
+}
+
+// CreateRenderer implements fyne.Widget.
+func (t *DataTable) CreateRenderer() fyne.WidgetRenderer {
+ t.initColWidths()
+ renderer := newDTRenderer(t)
+ t.rend = renderer
+ return renderer
+}
+
+func (t *DataTable) initColWidths() {
+ if len(t.colWidths) == len(t.Columns) {
+ return
+ }
+ t.colWidths = make([]float32, len(t.Columns))
+ for i, col := range t.Columns {
+ t.colWidths[i] = col.Width
+ }
+}
+
+func (t *DataTable) rowCount() int {
+ if t.RowCount == nil {
+ return 0
+ }
+ return t.RowCount()
+}
+
+func (t *DataTable) visibleSlots() int {
+ if t.rend == nil {
+ return 1
+ }
+ availHeight := t.rend.size.Height - dtHdrH
+ if availHeight < 0 {
+ return 0
+ }
+ return int(availHeight/dtRowH) + 2
+}
+
+// cellColor maps a widget.Importance value to a theme colour.
+func (t *DataTable) cellColor(row, col int) color.Color {
+ importance := widget.MediumImportance
+ if t.CellStyle != nil {
+ importance = t.CellStyle(row, col)
+ }
+ switch importance {
+ case widget.DangerImportance:
+ return theme.Color(theme.ColorNameError)
+ case widget.WarningImportance:
+ return theme.Color(theme.ColorNameWarning)
+ case widget.SuccessImportance:
+ return theme.Color(theme.ColorNameSuccess)
+ case widget.LowImportance:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case widget.HighImportance:
+ return theme.Color(theme.ColorNamePrimary)
+ default:
+ return theme.Color(theme.ColorNameForeground)
+ }
+}
+
+// minColWidth returns the minimum display width for col, measured from its
+// header text so the header is always fully readable.
+func (t *DataTable) minColWidth(col int) float32 {
+ if col >= len(t.Columns) {
+ return dtPadX * 2
+ }
+ textWidth := fyne.MeasureText(t.Columns[col].Header, theme.TextSize(), fyne.TextStyle{Bold: true}).Width
+ return textWidth + dtPadX*2
+}
+
+// thumbBounds returns the scrollbar thumb position and height in widget-local
+// coordinates. ok is false when all rows fit in the viewport.
+func (t *DataTable) thumbBounds() (thumbY, thumbH float32, ok bool) {
+ if t.rend == nil {
+ return 0, 0, false
+ }
+ totalRows := t.rowCount()
+ bodyHeight := t.rend.size.Height - dtHdrH
+ visibleRows := int(bodyHeight / dtRowH)
+ scrollable := totalRows - visibleRows
+ if scrollable <= 0 || bodyHeight <= 0 {
+ return 0, 0, false
+ }
+ ratio := float32(visibleRows) / float32(totalRows)
+ thumbH = bodyHeight * ratio
+ if thumbH < 20 {
+ thumbH = 20
+ }
+ trackHeight := bodyHeight - thumbH
+ thumbY = dtHdrH + (float32(t.scrollOffset)/float32(scrollable))*trackHeight
+ return thumbY, thumbH, true
+}
+
+func (t *DataTable) tappedRow(row int) {
+ if t.RowID != nil {
+ t.selectedID = t.RowID(row)
+ t.hasSelected = true
+ }
+ t.selectedRow = row
+ t.Refresh()
+ if t.OnSelect != nil {
+ t.OnSelect(row)
+ }
+ if t.win != nil {
+ t.win.Canvas().Focus(t)
+ }
+}
+
+// FocusGained, FocusLost, TypedRune, TypedKey implement fyne.Focusable.
+func (t *DataTable) FocusGained() { t.Refresh() }
+func (t *DataTable) FocusLost() { t.Refresh() }
+func (t *DataTable) TypedRune(_ rune) {}
+func (t *DataTable) TypedKey(ev *fyne.KeyEvent) {
+ if !t.hasSelected || t.RowCount == nil {
+ return
+ }
+ total := t.RowCount()
+ if total == 0 {
+ return
+ }
+ next := t.selectedRow
+ switch ev.Name {
+ case fyne.KeyUp:
+ next--
+ case fyne.KeyDown:
+ next++
+ default:
+ return
+ }
+ if next < 0 {
+ next = 0
+ }
+ if next >= total {
+ next = total - 1
+ }
+ if next == t.selectedRow {
+ return
+ }
+ t.ScrollToRow(next)
+ t.tappedRow(next)
+}
+
+func (t *DataTable) secondaryTappedRow(row int, absPos fyne.Position) {
+ if t.MenuItems == nil || t.win == nil {
+ return
+ }
+ items := t.MenuItems(row)
+ if len(items) == 0 {
+ return
+ }
+ menuItems := make([]*fyne.MenuItem, len(items))
+ for i, item := range items {
+ it := item
+ menuItems[i] = fyne.NewMenuItem(it.Label, it.Action)
+ }
+ widget.ShowPopUpMenuAtPosition(fyne.NewMenu("", menuItems...), t.win.Canvas(), absPos)
+}
+
+// dtRenderer draws the table using canvas primitives. Only visible row slots
+// are allocated; slots grow as the widget height increases but are never freed.
+type dtRenderer struct {
+ table *DataTable
+ size fyne.Size
+
+ headerBg *canvas.Rectangle
+ headerTexts []*canvas.Text
+ dividerLines []*canvas.Line
+
+ slots int
+ rowBgs []*canvas.Rectangle
+ cellTexts [][]*canvas.Text
+
+ scrollTrack *canvas.Rectangle
+ scrollThumb *canvas.Rectangle
+
+ objs []fyne.CanvasObject // cached; rebuilt only when slots grow
+}
+
+func newDTRenderer(table *DataTable) *dtRenderer {
+ nCols := len(table.Columns)
+ r := &dtRenderer{table: table}
+ r.headerBg = canvas.NewRectangle(color.Transparent)
+
+ r.headerTexts = make([]*canvas.Text, nCols)
+ for i, col := range table.Columns {
+ headerText := canvas.NewText(col.Header, color.Transparent)
+ headerText.TextStyle = fyne.TextStyle{Bold: true}
+ headerText.TextSize = theme.TextSize()
+ r.headerTexts[i] = headerText
+ }
+
+ if nCols > 1 {
+ r.dividerLines = make([]*canvas.Line, nCols-1)
+ for i := range r.dividerLines {
+ dividerLine := canvas.NewLine(color.Transparent)
+ dividerLine.StrokeWidth = 1
+ r.dividerLines[i] = dividerLine
+ }
+ }
+
+ r.scrollTrack = canvas.NewRectangle(color.Transparent)
+ r.scrollThumb = canvas.NewRectangle(color.Transparent)
+ r.rebuildObjects()
+ return r
+}
+
+func (r *dtRenderer) Destroy() {}
+
+func (r *dtRenderer) Objects() []fyne.CanvasObject { return r.objs }
+
+func (r *dtRenderer) MinSize() fyne.Size {
+ return fyne.NewSize(100, dtHdrH+dtRowH*3)
+}
+
+func (r *dtRenderer) Layout(size fyne.Size) {
+ r.size = size
+ r.growSlots()
+ r.layoutHeader()
+ r.layoutBody()
+ r.layoutScrollbar()
+}
+
+func (r *dtRenderer) Refresh() {
+ r.table.initColWidths()
+ r.growSlots()
+ r.layoutHeader()
+ r.layoutBody()
+ r.updateCells()
+ r.layoutScrollbar()
+ canvas.Refresh(r.table)
+}
+
+// rebuildObjects reconstructs the cached object slice after slots grow.
+func (r *dtRenderer) rebuildObjects() {
+ nFixed := 1 + len(r.headerTexts) + len(r.dividerLines) + 2
+ total := nFixed + len(r.rowBgs)
+ for _, row := range r.cellTexts {
+ total += len(row)
+ }
+ objs := make([]fyne.CanvasObject, 0, total)
+ objs = append(objs, r.headerBg)
+ for _, headerText := range r.headerTexts {
+ objs = append(objs, headerText)
+ }
+ for _, dividerLine := range r.dividerLines {
+ objs = append(objs, dividerLine)
+ }
+ for _, rowBg := range r.rowBgs {
+ objs = append(objs, rowBg)
+ }
+ for _, row := range r.cellTexts {
+ for _, cellText := range row {
+ objs = append(objs, cellText)
+ }
+ }
+ objs = append(objs, r.scrollTrack, r.scrollThumb)
+ r.objs = objs
+}
+
+func (r *dtRenderer) growSlots() {
+ bodyHeight := r.size.Height - dtHdrH
+ if bodyHeight < 0 {
+ bodyHeight = 0
+ }
+ needed := int(bodyHeight/dtRowH) + 2
+ nCols := len(r.table.Columns)
+
+ grew := false
+ for r.slots < needed {
+ rowBg := canvas.NewRectangle(color.Transparent)
+ r.rowBgs = append(r.rowBgs, rowBg)
+ cellRow := make([]*canvas.Text, nCols)
+ for col := 0; col < nCols; col++ {
+ cellText := canvas.NewText("", color.Transparent)
+ cellText.TextSize = theme.TextSize()
+ cellRow[col] = cellText
+ }
+ r.cellTexts = append(r.cellTexts, cellRow)
+ r.slots++
+ grew = true
+ }
+ if grew {
+ r.rebuildObjects()
+ }
+
+ for slot := needed; slot < r.slots; slot++ {
+ r.rowBgs[slot].Hide()
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Hide()
+ }
+ }
+}
+
+func (r *dtRenderer) layoutHeader() {
+ r.headerBg.Move(fyne.NewPos(0, 0))
+ r.headerBg.Resize(fyne.NewSize(r.size.Width, dtHdrH))
+ r.headerBg.FillColor = theme.Color(theme.ColorNameHeaderBackground)
+ r.headerBg.Refresh()
+
+ xPos := float32(0)
+ for i, headerText := range r.headerTexts {
+ if i >= len(r.table.colWidths) {
+ headerText.Hide()
+ continue
+ }
+ colWidth := r.table.colWidths[i]
+ headerText.Move(fyne.NewPos(xPos+dtPadX, (dtHdrH-theme.TextSize())/2))
+ headerText.Color = theme.Color(theme.ColorNameForeground)
+ headerText.Text = r.table.Columns[i].Header
+ headerText.Refresh()
+ headerText.Show()
+
+ if i < len(r.dividerLines) {
+ divX := xPos + colWidth - 0.5
+ r.dividerLines[i].Position1 = fyne.NewPos(divX, 4)
+ r.dividerLines[i].Position2 = fyne.NewPos(divX, dtHdrH-4)
+ r.dividerLines[i].StrokeColor = theme.Color(theme.ColorNameSeparator)
+ r.dividerLines[i].Refresh()
+ r.dividerLines[i].Show()
+ }
+ xPos += colWidth
+ }
+}
+
+func (r *dtRenderer) layoutBody() {
+ totalRows := r.table.rowCount()
+ needed := int((r.size.Height-dtHdrH)/dtRowH) + 2
+
+ yPos := dtHdrH
+ for slot := 0; slot < needed && slot < r.slots; slot++ {
+ dataIdx := r.table.scrollOffset + slot
+ rowBg := r.rowBgs[slot]
+ rowBg.Move(fyne.NewPos(0, yPos))
+ rowBg.Resize(fyne.NewSize(r.size.Width, dtRowH))
+
+ if dataIdx >= totalRows {
+ rowBg.FillColor = color.Transparent
+ rowBg.Refresh()
+ rowBg.Show()
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Hide()
+ }
+ yPos += dtRowH
+ continue
+ }
+
+ switch {
+ case r.table.hasSelected && r.table.RowID != nil && r.table.RowID(dataIdx) == r.table.selectedID:
+ rowBg.FillColor = theme.Color(theme.ColorNameSelection)
+ default:
+ if r.table.RowBgColor != nil {
+ if c := r.table.RowBgColor(dataIdx); c != nil {
+ rowBg.FillColor = c
+ } else {
+ rowBg.FillColor = theme.Color(theme.ColorNameBackground)
+ }
+ } else {
+ rowBg.FillColor = theme.Color(theme.ColorNameBackground)
+ }
+ }
+ rowBg.Refresh()
+ rowBg.Show()
+
+ xPos := float32(0)
+ for col, colWidth := range r.table.colWidths {
+ if col >= len(r.cellTexts[slot]) {
+ break
+ }
+ cellText := r.cellTexts[slot][col]
+ cellText.Move(fyne.NewPos(xPos+dtPadX, yPos+(dtRowH-theme.TextSize())/2))
+ cellText.Show()
+ xPos += colWidth
+ }
+ yPos += dtRowH
+ }
+}
+
+func (r *dtRenderer) layoutScrollbar() {
+ totalRows := r.table.rowCount()
+ bodyHeight := r.size.Height - dtHdrH
+ trackW := dtScrollW
+ trackX := r.size.Width - trackW
+ if r.table.scrollbarHovered {
+ trackW = dtScrollW + 6
+ trackX = r.size.Width - trackW
+ }
+
+ r.scrollTrack.Move(fyne.NewPos(trackX, dtHdrH))
+ r.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight))
+
+ visibleRows := int(bodyHeight / dtRowH)
+ if totalRows <= visibleRows {
+ r.scrollTrack.FillColor = color.Transparent
+ r.scrollTrack.Refresh()
+ r.scrollThumb.FillColor = color.Transparent
+ r.scrollThumb.Refresh()
+ return
+ }
+
+ r.scrollTrack.FillColor = theme.Color(theme.ColorNameScrollBar)
+ r.scrollTrack.Refresh()
+
+ thumbY, thumbH, ok := r.table.thumbBounds()
+ if !ok {
+ r.scrollThumb.FillColor = color.Transparent
+ r.scrollThumb.Refresh()
+ return
+ }
+
+ thumbW := trackW - 2
+ r.scrollThumb.Move(fyne.NewPos(trackX+1, thumbY))
+ r.scrollThumb.Resize(fyne.NewSize(thumbW, thumbH))
+ r.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground)
+ r.scrollThumb.CornerRadius = thumbW / 2
+ r.scrollThumb.Refresh()
+}
+
+func (r *dtRenderer) updateCells() {
+ totalRows := r.table.rowCount()
+ needed := int((r.size.Height-dtHdrH)/dtRowH) + 2
+
+ for slot := 0; slot < needed && slot < r.slots; slot++ {
+ dataIdx := r.table.scrollOffset + slot
+ if dataIdx >= totalRows {
+ for _, cellText := range r.cellTexts[slot] {
+ cellText.Text = ""
+ cellText.Refresh()
+ }
+ continue
+ }
+ for col, cellText := range r.cellTexts[slot] {
+ if col >= len(r.table.colWidths) {
+ cellText.Text = ""
+ cellText.Refresh()
+ continue
+ }
+ if r.table.CellValue != nil {
+ cellText.Text = truncateText(r.table.CellValue(dataIdx, col), cellText.TextSize, cellText.TextStyle, r.table.colWidths[col]-dtPadX*2)
+ }
+ cellText.Color = r.table.cellColor(dataIdx, col)
+ cellText.Refresh()
+ }
+ }
+}
+
+// truncateText shortens s to fit within maxWidth pixels, appending an ellipsis.
+func truncateText(s string, size float32, style fyne.TextStyle, maxWidth float32) string {
+ if maxWidth <= 0 {
+ return ""
+ }
+ if fyne.MeasureText(s, size, style).Width <= maxWidth {
+ return s
+ }
+ const ellipsis = "..."
+ budget := maxWidth - fyne.MeasureText(ellipsis, size, style).Width
+ if budget <= 0 {
+ return ellipsis
+ }
+ runes := []rune(s)
+ lo, hi := 0, len(runes)
+ for lo < hi {
+ mid := (lo + hi + 1) / 2
+ if fyne.MeasureText(string(runes[:mid]), size, style).Width <= budget {
+ lo = mid
+ } else {
+ hi = mid - 1
+ }
+ }
+ return string(runes[:lo]) + ellipsis
+}
+
+// dtInputLayer is a transparent widget overlaid on DataTable via
+// container.NewStack. It routes all pointer events: row taps, context menus,
+// scrollbar thumb drag, and mouse-wheel scroll.
+type dtInputLayer struct {
+ widget.BaseWidget
+ table *DataTable
+ dividers []*colDivider
+ draggingThumb bool
+}
+
+func newDTInputLayer(table *DataTable) *dtInputLayer {
+ layer := &dtInputLayer{table: table}
+ layer.ExtendBaseWidget(layer)
+ for i := 0; i < len(table.Columns)-1; i++ {
+ layer.dividers = append(layer.dividers, newColDivider(table, i))
+ }
+ return layer
+}
+
+func (layer *dtInputLayer) CreateRenderer() fyne.WidgetRenderer {
+ objs := make([]fyne.CanvasObject, len(layer.dividers))
+ for i, divider := range layer.dividers {
+ objs[i] = divider
+ }
+ return &dtInputRenderer{layer: layer, objs: objs}
+}
+
+func (layer *dtInputLayer) inScrollbarArea(xPos float32) bool {
+ if layer.table.rend == nil {
+ return false
+ }
+ return xPos >= layer.table.rend.size.Width-dtScrollW
+}
+
+func (layer *dtInputLayer) inThumb(xPos, yPos float32) bool {
+ if !layer.inScrollbarArea(xPos) {
+ return false
+ }
+ thumbY, thumbH, ok := layer.table.thumbBounds()
+ if !ok {
+ return false
+ }
+ return yPos >= thumbY && yPos <= thumbY+thumbH
+}
+
+func (layer *dtInputLayer) rowAtY(yPos float32) int {
+ if yPos < dtHdrH {
+ return -1
+ }
+ dataIdx := layer.table.scrollOffset + int((yPos-dtHdrH)/dtRowH)
+ if dataIdx < 0 || dataIdx >= layer.table.rowCount() {
+ return -1
+ }
+ return dataIdx
+}
+
+func (layer *dtInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.table.Scrolled(ev) }
+
+func (layer *dtInputLayer) Tapped(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ if row := layer.rowAtY(ev.Position.Y); row >= 0 {
+ layer.table.tappedRow(row)
+ }
+}
+
+func (layer *dtInputLayer) TappedSecondary(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ if row := layer.rowAtY(ev.Position.Y); row >= 0 {
+ layer.table.secondaryTappedRow(row, ev.AbsolutePosition)
+ }
+}
+
+func (layer *dtInputLayer) MouseDown(ev *desktop.MouseEvent) {
+ layer.draggingThumb = layer.inThumb(ev.Position.X, ev.Position.Y)
+}
+
+func (layer *dtInputLayer) MouseUp(_ *desktop.MouseEvent) { layer.draggingThumb = false }
+
+func (layer *dtInputLayer) Dragged(ev *fyne.DragEvent) {
+ if !layer.draggingThumb {
+ return
+ }
+ table := layer.table
+ if table.rend == nil {
+ return
+ }
+ widgetPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(table)
+ if ev.AbsolutePosition.Y < widgetPos.Y || ev.AbsolutePosition.Y > widgetPos.Y+table.Size().Height {
+ return
+ }
+ _, thumbH, ok := table.thumbBounds()
+ if !ok {
+ return
+ }
+ bodyHeight := table.rend.size.Height - dtHdrH
+ trackHeight := bodyHeight - thumbH
+ if trackHeight <= 0 {
+ return
+ }
+ scrollable := table.rowCount() - int(bodyHeight/dtRowH)
+ if scrollable <= 0 {
+ return
+ }
+ thumbTop := ev.Position.Y - dtHdrH - thumbH/2
+ if thumbTop < 0 {
+ thumbTop = 0
+ }
+ if thumbTop > trackHeight {
+ thumbTop = trackHeight
+ }
+ table.scrollOffset = int((thumbTop / trackHeight) * float32(scrollable))
+ table.Refresh()
+}
+
+func (layer *dtInputLayer) DragEnd() { layer.draggingThumb = false }
+func (layer *dtInputLayer) MouseIn(_ *desktop.MouseEvent) {}
+func (layer *dtInputLayer) MouseOut() {
+ if layer.table.scrollbarHovered {
+ layer.table.scrollbarHovered = false
+ layer.table.Refresh()
+ }
+}
+func (layer *dtInputLayer) MouseMoved(ev *desktop.MouseEvent) {
+ inScrollbar := layer.inScrollbarArea(ev.Position.X)
+ if inScrollbar != layer.table.scrollbarHovered {
+ layer.table.scrollbarHovered = inScrollbar
+ layer.table.Refresh()
+ }
+}
+
+// dtInputRenderer positions column-divider widgets within the input layer.
+type dtInputRenderer struct {
+ layer *dtInputLayer
+ objs []fyne.CanvasObject
+}
+
+func (r *dtInputRenderer) Destroy() {}
+func (r *dtInputRenderer) MinSize() fyne.Size { return fyne.NewSize(0, 0) }
+func (r *dtInputRenderer) Objects() []fyne.CanvasObject { return r.objs }
+
+func (r *dtInputRenderer) Layout(size fyne.Size) {
+ table := r.layer.table
+ xPos := float32(0)
+ for i, divider := range r.layer.dividers {
+ if i >= len(table.colWidths) {
+ break
+ }
+ xPos += table.colWidths[i]
+ divider.Move(fyne.NewPos(xPos-dtDivW/2, 0))
+ divider.Resize(fyne.NewSize(dtDivW, size.Height))
+ }
+}
+
+func (r *dtInputRenderer) Refresh() {
+ r.Layout(r.layer.Size())
+ canvas.Refresh(r.layer)
+}
+
+// colDivider is a transparent widget positioned at each column boundary.
+// It presents an H-resize cursor and handles column-resize drag events.
+type colDivider struct {
+ widget.BaseWidget
+ table *DataTable
+ col int // index of the column to the left of this divider
+}
+
+func newColDivider(table *DataTable, col int) *colDivider {
+ divider := &colDivider{table: table, col: col}
+ divider.ExtendBaseWidget(divider)
+ return divider
+}
+
+func (d *colDivider) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
+}
+
+func (d *colDivider) Cursor() desktop.Cursor { return desktop.HResizeCursor }
+func (d *colDivider) MouseIn(_ *desktop.MouseEvent) {}
+func (d *colDivider) MouseOut() {}
+func (d *colDivider) MouseMoved(_ *desktop.MouseEvent) {}
+
+// Dragged resizes the column to the left of this divider.
+func (d *colDivider) Dragged(ev *fyne.DragEvent) {
+ if d.col >= len(d.table.colWidths) {
+ return
+ }
+
+ delta := ev.Dragged.DX
+ leftWidth := d.table.colWidths[d.col]
+ leftMin := d.table.minColWidth(d.col)
+
+ if leftWidth+delta < leftMin {
+ delta = leftMin - leftWidth
+ }
+ if delta == 0 {
+ return
+ }
+
+ if d.col+1 < len(d.table.colWidths) {
+ rightWidth := d.table.colWidths[d.col+1]
+ rightMin := d.table.minColWidth(d.col + 1)
+
+ if delta > 0 {
+ spareSpace := float32(0)
+ if d.table.rend != nil {
+ totalWidth := float32(0)
+ for _, colWidth := range d.table.colWidths {
+ totalWidth += colWidth
+ }
+ spareSpace = d.table.rend.size.Width - totalWidth - dtScrollW
+ if spareSpace < 0 {
+ spareSpace = 0
+ }
+ }
+ takenFromRight := delta - spareSpace
+ if takenFromRight > 0 {
+ if rightWidth-takenFromRight < rightMin {
+ takenFromRight = rightWidth - rightMin
+ }
+ d.table.colWidths[d.col+1] -= takenFromRight
+ delta = spareSpace + takenFromRight
+ }
+ } else {
+ d.table.colWidths[d.col+1] -= delta
+ }
+ }
+
+ d.table.colWidths[d.col] = leftWidth + delta
+ d.table.Refresh()
+ if d.table.inputLayer != nil {
+ d.table.inputLayer.Refresh()
+ }
+}
+
+func (d *colDivider) DragEnd() {}
diff --git a/internal/ui/widgets/datatable_test.go b/internal/ui/widgets/datatable_test.go
new file mode 100644
index 0000000..c0c7092
--- /dev/null
+++ b/internal/ui/widgets/datatable_test.go
@@ -0,0 +1,199 @@
+package widgets
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestTable(rowCount int) *DataTable {
+ dt := NewDataTable()
+ dt.Columns = []DataTableColumn{
+ {Header: "Col A", Width: 100},
+ {Header: "Col B", Width: 200},
+ }
+ dt.RowCount = func() int { return rowCount }
+ dt.RowID = func(row int) int64 { return int64(row + 1) }
+ dt.CellValue = func(row, col int) string { return "cell" }
+ return dt
+}
+
+// ── rowCount ─────────────────────────────────────────────────────────────────
+
+func TestDataTable_RowCount_Nil(t *testing.T) {
+ dt := NewDataTable()
+ assert.Equal(t, 0, dt.rowCount())
+}
+
+func TestDataTable_RowCount(t *testing.T) {
+ dt := newTestTable(42)
+ assert.Equal(t, 42, dt.rowCount())
+}
+
+// ── initColWidths ────────────────────────────────────────────────────────────
+
+func TestDataTable_InitColWidths(t *testing.T) {
+ dt := newTestTable(0)
+ dt.initColWidths()
+
+ require.Len(t, dt.colWidths, 2)
+ assert.Equal(t, float32(100), dt.colWidths[0])
+ assert.Equal(t, float32(200), dt.colWidths[1])
+}
+
+func TestDataTable_InitColWidths_Idempotent(t *testing.T) {
+ dt := newTestTable(0)
+ dt.initColWidths()
+ dt.colWidths[0] = 999
+ dt.initColWidths()
+
+ assert.Equal(t, float32(999), dt.colWidths[0], "initColWidths must not overwrite existing widths")
+}
+
+// ── SetSelected / ClearSelection ─────────────────────────────────────────────
+
+func TestDataTable_SetSelected(t *testing.T) {
+ dt := newTestTable(5)
+ dt.hasSelected = false
+
+ dt.selectedID = 3
+ dt.hasSelected = true
+
+ assert.True(t, dt.hasSelected)
+ assert.Equal(t, int64(3), dt.selectedID)
+}
+
+func TestDataTable_SetSelected_Zero_Clears(t *testing.T) {
+ dt := newTestTable(5)
+ dt.selectedID = 3
+ dt.hasSelected = true
+
+ dt.selectedID = 0
+ dt.hasSelected = false
+
+ assert.False(t, dt.hasSelected)
+}
+
+func TestDataTable_ClearSelection(t *testing.T) {
+ dt := newTestTable(5)
+ dt.selectedID = 5
+ dt.hasSelected = true
+
+ dt.selectedID = 0
+ dt.hasSelected = false
+
+ assert.False(t, dt.hasSelected)
+ assert.Equal(t, int64(0), dt.selectedID)
+}
+
+// ── tappedRow ────────────────────────────────────────────────────────────────
+
+func TestDataTable_TappedRow_SetsSelectionID(t *testing.T) {
+ dt := newTestTable(5)
+ dt.OnSelect = func(row int) {}
+ dt.rend = &dtRenderer{table: dt}
+
+ // Call internal logic directly without triggering Refresh.
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+
+ assert.True(t, dt.hasSelected)
+ assert.Equal(t, int64(3), dt.selectedID) // RowID = row+1
+}
+
+func TestDataTable_TappedRow_FiresOnSelect(t *testing.T) {
+ dt := newTestTable(5)
+ fired := -1
+ dt.OnSelect = func(row int) { fired = row }
+
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+ dt.OnSelect(2)
+
+ assert.Equal(t, 2, fired)
+}
+
+func TestDataTable_TappedRow_NoRowID_NoSelection(t *testing.T) {
+ dt := newTestTable(5)
+ dt.RowID = nil
+
+ if dt.RowID != nil {
+ dt.selectedID = dt.RowID(2)
+ dt.hasSelected = true
+ }
+
+ assert.False(t, dt.hasSelected)
+}
+
+// ── ScrollToRow ──────────────────────────────────────────────────────────────
+
+func TestDataTable_ScrollToRow_BelowViewport(t *testing.T) {
+ dt := newTestTable(100)
+ dt.rend = &dtRenderer{table: dt}
+ dt.scrollOffset = 0
+
+ row := 50
+ visible := 5 // simulated
+ if row >= dt.scrollOffset+visible {
+ dt.scrollOffset = row - visible + 1
+ }
+
+ assert.Greater(t, dt.scrollOffset, 0)
+}
+
+func TestDataTable_ScrollToRow_AboveViewport(t *testing.T) {
+ dt := newTestTable(100)
+ dt.scrollOffset = 30
+
+ row := 5
+ if row < dt.scrollOffset {
+ dt.scrollOffset = row
+ }
+
+ assert.Equal(t, 5, dt.scrollOffset)
+}
+
+func TestDataTable_ScrollToRow_NegativeClamp(t *testing.T) {
+ dt := newTestTable(10)
+ dt.scrollOffset = 5
+
+ dt.scrollOffset = 0
+ if dt.scrollOffset < 0 {
+ dt.scrollOffset = 0
+ }
+
+ assert.GreaterOrEqual(t, dt.scrollOffset, 0)
+}
+
+// ── thumbBounds ──────────────────────────────────────────────────────────────
+
+func TestDataTable_ThumbBounds_NoRenderer(t *testing.T) {
+ dt := newTestTable(50)
+ _, _, ok := dt.thumbBounds()
+ assert.False(t, ok)
+}
+
+func TestDataTable_ThumbBounds_AllRowsFit(t *testing.T) {
+ dt := newTestTable(3)
+ // Simulate a large viewport where all rows fit.
+ dt.rend = &dtRenderer{table: dt}
+ // 3 rows × 32px = 96px. Set viewport height > that.
+ dt.rend.size.Height = 500
+ _, _, ok := dt.thumbBounds()
+ assert.False(t, ok, "all rows fit — thumb not needed")
+}
+
+func TestDataTable_ThumbBounds_NeedsScroll(t *testing.T) {
+ dt := newTestTable(100)
+ dt.rend = &dtRenderer{table: dt}
+ dt.rend.size.Height = 200 // small viewport
+ _, thumbH, ok := dt.thumbBounds()
+
+ assert.True(t, ok)
+ assert.Greater(t, thumbH, float32(0))
+}
diff --git a/internal/ui/widgets/textview.go b/internal/ui/widgets/textview.go
new file mode 100644
index 0000000..6ec4b1e
--- /dev/null
+++ b/internal/ui/widgets/textview.go
@@ -0,0 +1,836 @@
+package widgets
+
+// TextView is a virtualised, syntax-highlighted, selectable read-only text
+// widget for Fyne.
+//
+// Architecture
+//
+// Fyne's widget.Entry performs a full layout pass on every SetText call,
+// making it unusable for large HTTP responses (minified JS/CSS etc.).
+// TextView solves this with the same virtualisation principle as DataTable:
+// only the visible lines are rendered as canvas objects.
+//
+// Pipeline
+//
+// SetText(s)
+// └─▶ goroutine: parse + wrap + tokenise (never blocks the UI thread)
+// └─▶ fyne.Do: store []tvLine, Refresh()
+// └─▶ renderer: draw only visible lines
+//
+// Syntax highlighting
+//
+// HTTP-aware tokeniser:
+// - Request line — method (error colour), path (primary), version (dimmed)
+// - Response line — version (dimmed), status code (success/warning/error), status text (dimmed)
+// - Header name — primary
+// - Header value — foreground
+// - Body (JSON) — keys (primary), strings (success), numbers (warning), booleans/null (error)
+// - Body (other) — plain foreground
+//
+// Text selection
+//
+// Selection is tracked as (line, col) pairs in visual (wrapped) line coordinates.
+// Click sets the anchor, drag extends, Ctrl+A selects all, Ctrl+C copies to clipboard.
+//
+// Scrolling
+//
+// Vertical only. Mouse wheel and scrollbar thumb drag both work.
+
+import (
+ "image/color"
+ "math"
+ "strings"
+ "sync"
+ "unicode"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+// Layout constants for TextView. All sizes are in logical pixels.
+const (
+ tvLineH float32 = 20 // height of one text line
+ tvPadX float32 = 8 // left and right margin
+ tvPadY float32 = 4 // top and bottom margin
+ tvScrollW float32 = 8 // scrollbar track width
+ tvScrollMinThumb float32 = 20 // minimum scrollbar thumb height
+ tvMaxTokensPerLine = 64 // canvas text objects allocated per visible line
+)
+
+// TextView is the public widget. Use newTextView to create it.
+// Call SetText to load content; parsing never blocks the UI thread.
+// Use Build to obtain the canvas object to place in a layout.
+type TextView struct {
+ widget.BaseWidget
+
+ mu sync.RWMutex
+ lines []tvLine // processed visual lines, populated by background parser
+ rawFull string // original unprocessed text, used for select-all copy
+
+ scrollOffset int // index of the first visible line
+ scrollFrac float32 // sub-line accumulator for smooth trackpad scrolling
+
+ // Selection is tracked as anchor + cursor (line, col) pairs in visual-line
+ // coordinates. Anchor is set on mouse down; cursor moves during drag.
+ selAnchorLine int
+ selAnchorCol int
+ selLine int
+ selCol int
+ hasSelection bool
+
+ win fyne.Window
+ rend *tvRenderer
+
+ scrollbarHovered bool // true while mouse is over scrollbar track or thumb — widens thumb
+
+ pendingThumb *tvScrollThumb // created in Build before rend exists; wired in CreateRenderer
+ lastWrapWidth float32 // wrap width used for the last parse pass
+ pendingText string // text waiting to be parsed once width is known
+ processing bool // true while the background parse goroutine is running
+ gen int64
+}
+
+func NewTextView() *TextView {
+ view := &TextView{}
+ view.ExtendBaseWidget(view)
+ return view
+}
+
+// SetWindow provides the window reference required for clipboard access.
+// Must be called before the widget is shown.
+func (view *TextView) SetWindow(w fyne.Window) { view.win = w }
+
+// SetText replaces the displayed content. Parsing and line-wrapping run on a
+// background goroutine so the UI thread is never blocked.
+func (view *TextView) SetText(s string) {
+ view.mu.Lock()
+ view.pendingText = s
+ view.rawFull = s
+ view.scrollOffset = 0
+ view.scrollFrac = 0
+ view.hasSelection = false
+ alreadyProcessing := view.processing
+ currentWidth := view.lastWrapWidth
+ view.mu.Unlock()
+
+ if alreadyProcessing || currentWidth < 1 {
+ return
+ }
+ view.startProcessing(s, currentWidth)
+}
+
+// SetPlaceHolder is a no-op retained for API compatibility.
+func (view *TextView) SetPlaceHolder(_ string) {}
+
+// GetText returns the current raw text content.
+func (view *TextView) GetText() string {
+ view.mu.RLock()
+ defer view.mu.RUnlock()
+ return view.rawFull
+}
+
+func (view *TextView) startProcessing(s string, wrapWidth float32) {
+ view.mu.Lock()
+ view.gen++
+ currentGen := view.gen
+ view.processing = true
+ view.mu.Unlock()
+
+ go func(gen int64, text string, width float32) {
+ parsedLines := parseAndWrap(text, width)
+
+ fyne.Do(func() {
+ view.mu.Lock()
+ if gen != view.gen {
+ view.mu.Unlock()
+ return
+ }
+
+ view.lines = parsedLines
+ view.processing = false
+
+ pending := ""
+ if view.pendingText != text {
+ pending = view.pendingText
+ }
+ latestWidth := view.lastWrapWidth
+ view.mu.Unlock()
+
+ if pending != "" && latestWidth > 0 {
+ view.startProcessing(pending, latestWidth)
+ }
+
+ view.Refresh()
+ })
+ }(currentGen, s, wrapWidth)
+}
+
+// CreateRenderer implements fyne.Widget.
+func (view *TextView) CreateRenderer() fyne.WidgetRenderer {
+ renderer := newTVRenderer(view)
+ view.rend = renderer
+ if view.pendingThumb != nil {
+ renderer.thumbWidget = view.pendingThumb
+ view.pendingThumb = nil
+ }
+ return renderer
+}
+
+// Scrolled implements fyne.Scrollable.
+func (view *TextView) Scrolled(ev *fyne.ScrollEvent) {
+ view.mu.Lock()
+
+ totalLines := len(view.lines)
+
+ view.scrollFrac -= ev.Scrolled.DY / tvLineH
+ rows := int(view.scrollFrac)
+ view.scrollFrac -= float32(rows)
+ if rows == 0 {
+ if ev.Scrolled.DY < 0 {
+ rows = 1
+ } else if ev.Scrolled.DY > 0 {
+ rows = -1
+ }
+ }
+ view.scrollOffset += rows
+
+ visible := view.visibleLineCount()
+ maxOffset := max(totalLines-visible, 0)
+ if view.scrollOffset < 0 {
+ view.scrollOffset = 0
+ }
+ if view.scrollOffset > maxOffset {
+ view.scrollOffset = maxOffset
+ }
+
+ view.mu.Unlock()
+ view.Refresh()
+}
+
+func (view *TextView) visibleLineCount() int {
+ if view.rend == nil {
+ return 0
+ }
+ availHeight := view.rend.size.Height - tvPadY*2
+ if availHeight < 0 {
+ return 0
+ }
+ return int(availHeight / tvLineH)
+}
+
+// posFromPoint converts a canvas-local position to a (line, col) pair in
+// visual-line coordinates.
+func (view *TextView) posFromPoint(point fyne.Position) (line, col int) {
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ view.mu.RUnlock()
+
+ lineIdx := max(view.scrollOffset+int((point.Y-tvPadY)/tvLineH), 0)
+ if lineIdx >= totalLines {
+ lineIdx = totalLines - 1
+ }
+ if lineIdx < 0 {
+ return 0, 0
+ }
+
+ view.mu.RLock()
+ rawLine := ""
+ if lineIdx < len(view.lines) {
+ rawLine = view.lines[lineIdx].Raw
+ }
+ view.mu.RUnlock()
+
+ runes := []rune(rawLine)
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ col = min(max(int((point.X-tvPadX)/charWidth), 0), len(runes))
+ return lineIdx, col
+}
+
+// selectedText returns the currently selected plain text.
+func (view *TextView) selectedText() string {
+ if !view.hasSelection {
+ return ""
+ }
+ view.mu.RLock()
+ defer view.mu.RUnlock()
+
+ startLine, startCol, endLine, endCol := view.normaliseSelection()
+ if startLine == endLine {
+ runes := []rune(view.lines[startLine].Raw)
+ if startCol > len(runes) {
+ startCol = len(runes)
+ }
+ if endCol > len(runes) {
+ endCol = len(runes)
+ }
+ return string(runes[startCol:endCol])
+ }
+
+ var builder strings.Builder
+ for lineIdx := startLine; lineIdx <= endLine; lineIdx++ {
+ if lineIdx >= len(view.lines) {
+ break
+ }
+ runes := []rune(view.lines[lineIdx].Raw)
+ selStart, selEnd := 0, len(runes)
+ if lineIdx == startLine {
+ selStart = startCol
+ }
+ if lineIdx == endLine {
+ selEnd = endCol
+ if selEnd > len(runes) {
+ selEnd = len(runes)
+ }
+ }
+ if selStart > len(runes) {
+ selStart = len(runes)
+ }
+ builder.WriteString(string(runes[selStart:selEnd]))
+ if lineIdx < endLine {
+ builder.WriteByte('\n')
+ }
+ }
+ return builder.String()
+}
+
+// normaliseSelection returns (startLine, startCol, endLine, endCol) in reading order.
+func (view *TextView) normaliseSelection() (int, int, int, int) {
+ anchorLine, anchorCol := view.selAnchorLine, view.selAnchorCol
+ cursorLine, cursorCol := view.selLine, view.selCol
+ if anchorLine > cursorLine || (anchorLine == cursorLine && anchorCol > cursorCol) {
+ return cursorLine, cursorCol, anchorLine, anchorCol
+ }
+ return anchorLine, anchorCol, cursorLine, cursorCol
+}
+
+func (view *TextView) TypedShortcut(s fyne.Shortcut) {
+ switch s.(type) {
+ case *fyne.ShortcutCopy:
+ if view.win != nil && view.hasSelection {
+ fyne.CurrentApp().Clipboard().SetContent(view.selectedText())
+ }
+ case *fyne.ShortcutSelectAll:
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ lastRaw := ""
+ if totalLines > 0 {
+ lastRaw = view.lines[totalLines-1].Raw
+ }
+ view.mu.RUnlock()
+ view.selAnchorLine, view.selAnchorCol = 0, 0
+ view.selLine = max(totalLines-1, 0)
+ view.selCol = len([]rune(lastRaw))
+ view.hasSelection = true
+ view.Refresh()
+ }
+}
+
+func (view *TextView) TypedKey(_ *fyne.KeyEvent) {}
+func (view *TextView) TypedRune(_ rune) {}
+func (view *TextView) FocusGained() { view.Refresh() }
+func (view *TextView) FocusLost() {
+ view.hasSelection = false
+ view.Refresh()
+}
+
+// Build constructs the complete canvas object to place in a layout.
+// Call once after all callbacks are configured.
+func (view *TextView) Build() fyne.CanvasObject {
+ thumb := newTVScrollThumb(view)
+ view.pendingThumb = thumb
+ return container.NewStack(view, newTVInputLayer(view), thumb)
+}
+
+// tvInputLayer is a transparent widget overlaid on TextView via
+// container.NewStack. It handles all pointer input: text selection,
+// scrollbar interaction, and mouse-wheel scroll forwarding.
+type tvInputLayer struct {
+ widget.BaseWidget
+ view *TextView
+}
+
+func newTVInputLayer(view *TextView) *tvInputLayer {
+ layer := &tvInputLayer{view: view}
+ layer.ExtendBaseWidget(layer)
+ return layer
+}
+
+func (layer *tvInputLayer) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
+}
+
+func (layer *tvInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.view.Scrolled(ev) }
+
+func (layer *tvInputLayer) inScrollbarArea(xPos float32) bool {
+ if layer.view.rend == nil {
+ return false
+ }
+ return xPos >= layer.view.rend.size.Width-tvScrollW
+}
+
+// MouseDown sets the selection anchor on button press so drag always starts
+// from the correct position.
+func (layer *tvInputLayer) MouseDown(ev *desktop.MouseEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.selAnchorLine, view.selAnchorCol = line, col
+ view.selLine, view.selCol = line, col
+ view.hasSelection = false
+ view.Refresh()
+ if view.win != nil {
+ view.win.Canvas().Focus(view)
+ }
+}
+
+func (layer *tvInputLayer) MouseUp(_ *desktop.MouseEvent) {}
+
+// Tapped satisfies fyne.Tappable; selection state is managed in MouseDown.
+func (layer *tvInputLayer) Tapped(_ *fyne.PointEvent) {}
+
+func (layer *tvInputLayer) DoubleTapped(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.mu.RLock()
+ rawLine := ""
+ if line < len(view.lines) {
+ rawLine = view.lines[line].Raw
+ }
+ view.mu.RUnlock()
+
+ runes := []rune(rawLine)
+ if len(runes) == 0 {
+ return
+ }
+ if col >= len(runes) {
+ col = len(runes) - 1
+ }
+
+ wordStart := col
+ for wordStart > 0 && isWordRune(runes[wordStart-1]) {
+ wordStart--
+ }
+ wordEnd := col
+ for wordEnd < len(runes) && isWordRune(runes[wordEnd]) {
+ wordEnd++
+ }
+ view.selAnchorLine, view.selAnchorCol = line, wordStart
+ view.selLine, view.selCol = line, wordEnd
+ view.hasSelection = wordStart != wordEnd
+ view.Refresh()
+}
+
+func (layer *tvInputLayer) Dragged(ev *fyne.DragEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ view := layer.view
+ line, col := view.posFromPoint(ev.Position)
+ view.selLine, view.selCol = line, col
+ view.hasSelection = !(view.selAnchorLine == view.selLine && view.selAnchorCol == view.selCol)
+ view.Refresh()
+}
+
+func (layer *tvInputLayer) DragEnd() {}
+func (layer *tvInputLayer) MouseIn(_ *desktop.MouseEvent) {}
+
+func (layer *tvInputLayer) MouseOut() {
+ if layer.view.scrollbarHovered {
+ layer.view.scrollbarHovered = false
+ layer.view.Refresh()
+ }
+}
+
+func (layer *tvInputLayer) MouseMoved(ev *desktop.MouseEvent) {
+ inScrollbar := layer.inScrollbarArea(ev.Position.X)
+ if inScrollbar != layer.view.scrollbarHovered {
+ layer.view.scrollbarHovered = inScrollbar
+ layer.view.Refresh()
+ }
+}
+
+func isWordRune(r rune) bool {
+ return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-'
+}
+
+// tvScrollThumb is a draggable widget overlaid on the scrollbar thumb visual.
+// It receives drag events and translates them to scroll offset changes.
+type tvScrollThumb struct {
+ widget.BaseWidget
+ view *TextView
+ rect *canvas.Rectangle
+}
+
+func newTVScrollThumb(view *TextView) *tvScrollThumb {
+ thumb := &tvScrollThumb{view: view}
+ thumb.rect = canvas.NewRectangle(color.Transparent)
+ thumb.ExtendBaseWidget(thumb)
+ return thumb
+}
+
+func (thumb *tvScrollThumb) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(thumb.rect)
+}
+
+func (thumb *tvScrollThumb) Cursor() desktop.Cursor { return desktop.DefaultCursor }
+
+func (thumb *tvScrollThumb) MouseIn(_ *desktop.MouseEvent) {
+ if !thumb.view.scrollbarHovered {
+ thumb.view.scrollbarHovered = true
+ thumb.view.Refresh()
+ }
+}
+
+func (thumb *tvScrollThumb) MouseOut() {
+ if thumb.view.scrollbarHovered {
+ thumb.view.scrollbarHovered = false
+ thumb.view.Refresh()
+ }
+}
+
+func (thumb *tvScrollThumb) MouseMoved(_ *desktop.MouseEvent) {}
+
+func (thumb *tvScrollThumb) Dragged(ev *fyne.DragEvent) {
+ view := thumb.view
+ view.mu.RLock()
+ totalLines := len(view.lines)
+ view.mu.RUnlock()
+
+ if view.rend == nil || totalLines == 0 {
+ return
+ }
+
+ widgetPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(view)
+ widgetBottom := widgetPos.Y + view.Size().Height
+ if ev.AbsolutePosition.Y < widgetPos.Y || ev.AbsolutePosition.Y > widgetBottom {
+ return
+ }
+
+ bodyHeight := view.rend.size.Height - tvPadY*2
+ visibleLines := int(bodyHeight / tvLineH)
+ scrollable := totalLines - visibleLines
+ if scrollable <= 0 {
+ return
+ }
+
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbHeight := bodyHeight * ratio
+ if thumbHeight < tvScrollMinThumb {
+ thumbHeight = tvScrollMinThumb
+ }
+ trackHeight := bodyHeight - thumbHeight
+ if trackHeight <= 0 {
+ return
+ }
+
+ newThumbTop := ev.AbsolutePosition.Y - widgetPos.Y - tvPadY - thumbHeight/2
+ if newThumbTop < 0 {
+ newThumbTop = 0
+ }
+ if newThumbTop > trackHeight {
+ newThumbTop = trackHeight
+ }
+
+ view.mu.Lock()
+ view.scrollOffset = int((newThumbTop / trackHeight) * float32(scrollable))
+ view.mu.Unlock()
+ view.Refresh()
+}
+
+func (thumb *tvScrollThumb) DragEnd() {}
+
+// tvRenderer draws the text content using canvas primitives.
+// Only visible line slots are allocated; slots grow as the widget grows taller.
+type tvRenderer struct {
+ view *TextView
+ size fyne.Size
+
+ selBgs []*canvas.Rectangle // one selection-highlight rectangle per visible slot
+ tokenSlots [][]*canvas.Text // [slot][tokenIndex] — pre-allocated token text objects
+ slots int
+
+ scrollTrack *canvas.Rectangle
+ scrollThumb *canvas.Rectangle // visual canvas rect for the scrollbar thumb
+ thumbWidget *tvScrollThumb // draggable overlay widget, positioned to match scrollThumb
+}
+
+func newTVRenderer(view *TextView) *tvRenderer {
+ renderer := &tvRenderer{view: view}
+ renderer.scrollTrack = canvas.NewRectangle(color.Transparent)
+ renderer.scrollThumb = canvas.NewRectangle(color.Transparent)
+ return renderer
+}
+
+func (renderer *tvRenderer) Destroy() {}
+
+func (renderer *tvRenderer) Objects() []fyne.CanvasObject {
+ total := 2 + len(renderer.selBgs) + len(renderer.tokenSlots)*tvMaxTokensPerLine
+ objs := make([]fyne.CanvasObject, 0, total)
+ objs = append(objs, renderer.scrollTrack, renderer.scrollThumb)
+ for _, selBg := range renderer.selBgs {
+ objs = append(objs, selBg)
+ }
+ for _, slot := range renderer.tokenSlots {
+ for _, tokenText := range slot {
+ objs = append(objs, tokenText)
+ }
+ }
+ return objs
+}
+
+func (renderer *tvRenderer) MinSize() fyne.Size {
+ return fyne.NewSize(200, tvLineH*3+tvPadY*2)
+}
+
+func (renderer *tvRenderer) Layout(size fyne.Size) {
+ renderer.size = size
+ renderer.checkWrapWidth()
+ renderer.growSlots()
+ renderer.layoutContent()
+}
+
+func (renderer *tvRenderer) Refresh() {
+ renderer.checkWrapWidth()
+ renderer.growSlots()
+ renderer.layoutContent()
+ canvas.Refresh(renderer.view)
+}
+
+// checkWrapWidth triggers a re-parse when the available text width changes.
+func (renderer *tvRenderer) checkWrapWidth() {
+ availWidth := renderer.size.Width - tvPadX*2 - tvScrollW
+ if availWidth < 1 {
+ return
+ }
+ renderer.view.mu.Lock()
+ prevWidth := renderer.view.lastWrapWidth
+ pendingText := renderer.view.pendingText
+ renderer.view.lastWrapWidth = availWidth
+ renderer.view.mu.Unlock()
+
+ if pendingText == "" {
+ return
+ }
+ if math.Abs(float64(prevWidth-availWidth)) > 4 || prevWidth < 1 {
+ renderer.view.startProcessing(pendingText, availWidth)
+ }
+}
+
+func (renderer *tvRenderer) growSlots() {
+ bodyHeight := renderer.size.Height - tvPadY*2
+ if bodyHeight < 0 {
+ bodyHeight = 0
+ }
+ needed := int(bodyHeight/tvLineH) + 2
+
+ // grow
+ for renderer.slots < needed {
+ selBg := canvas.NewRectangle(color.Transparent)
+ renderer.selBgs = append(renderer.selBgs, selBg)
+
+ slot := make([]*canvas.Text, tvMaxTokensPerLine)
+ for i := range slot {
+ t := canvas.NewText("", color.Transparent)
+ t.TextSize = theme.TextSize()
+ slot[i] = t
+ }
+ renderer.tokenSlots = append(renderer.tokenSlots, slot)
+
+ renderer.slots++
+ }
+
+ // hide unused (no truncation, keeps all content available)
+ for slot := needed; slot < renderer.slots; slot++ {
+ renderer.selBgs[slot].Hide()
+ for _, tokenText := range renderer.tokenSlots[slot] {
+ tokenText.Hide()
+ }
+ }
+}
+
+func (renderer *tvRenderer) layoutContent() {
+ renderer.view.mu.RLock()
+ lines := renderer.view.lines
+ scrollOffset := renderer.view.scrollOffset
+ hasSelection := renderer.view.hasSelection
+ var startLine, startCol, endLine, endCol int
+ if hasSelection {
+ startLine, startCol, endLine, endCol = renderer.view.normaliseSelection()
+ }
+ renderer.view.mu.RUnlock()
+
+ totalLines := len(lines)
+ needed := int((renderer.size.Height-tvPadY*2)/tvLineH) + 2
+ contentWidth := renderer.size.Width - tvScrollW
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+
+ yPos := tvPadY
+ for slot := 0; slot < needed && slot < renderer.slots; slot++ {
+ lineIdx := scrollOffset + slot
+ selBg := renderer.selBgs[slot]
+
+ if lineIdx >= totalLines {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ for _, tokenText := range renderer.tokenSlots[slot] {
+ tokenText.Hide()
+ }
+ yPos += tvLineH
+ continue
+ }
+
+ // selection highlight background
+ if hasSelection && lineIdx >= startLine && lineIdx <= endLine {
+ line := lines[lineIdx]
+ runes := []rune(line.Raw)
+ lineLen := len(runes)
+
+ selStart := 0
+ selEnd := lineLen
+ if lineIdx == startLine {
+ selStart = startCol
+ }
+ if lineIdx == endLine {
+ selEnd = endCol
+ }
+ if selStart > lineLen {
+ selStart = lineLen
+ }
+ if selEnd > lineLen {
+ selEnd = lineLen
+ }
+
+ selX := tvPadX + float32(selStart)*charWidth
+ selWidth := float32(selEnd-selStart) * charWidth
+ if lineIdx < endLine && selEnd == lineLen {
+ selWidth += charWidth
+ }
+ selBg.Move(fyne.NewPos(selX, yPos))
+ selBg.Resize(fyne.NewSize(selWidth, tvLineH))
+ selBg.FillColor = selectionColor()
+ selBg.Refresh()
+ selBg.Show()
+ } else {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ }
+
+ // token text objects
+ line := lines[lineIdx]
+ xPos := tvPadX
+ tokenIdx := 0
+ for _, token := range line.Tokens {
+ if tokenIdx >= tvMaxTokensPerLine {
+ break
+ }
+ if token.Text == "" {
+ continue
+ }
+ tokenText := renderer.tokenSlots[slot][tokenIdx]
+ maxWidth := contentWidth - xPos
+ displayText := truncateText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}, maxWidth)
+ if displayText == "" {
+ tokenText.Hide()
+ tokenIdx++
+ continue
+ }
+ tokenText.Text = displayText
+ tokenText.Color = tvColor(token.Kind)
+ tokenText.TextStyle = fyne.TextStyle{Monospace: true}
+ tokenText.TextSize = theme.TextSize()
+ tokenText.Move(fyne.NewPos(xPos, yPos))
+ tokenText.Refresh()
+ tokenText.Show()
+ xPos += fyne.MeasureText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ tokenIdx++
+ }
+ for ; tokenIdx < tvMaxTokensPerLine; tokenIdx++ {
+ renderer.tokenSlots[slot][tokenIdx].Hide()
+ }
+
+ yPos += tvLineH
+ }
+
+ renderer.layoutScrollbar(totalLines)
+}
+
+func (renderer *tvRenderer) layoutScrollbar(totalLines int) {
+ bodyHeight := renderer.size.Height - tvPadY*2
+ trackW := float32(tvScrollW)
+ trackX := renderer.size.Width - trackW
+ if renderer.view.scrollbarHovered {
+ trackW = tvScrollW + 6
+ trackX = renderer.size.Width - trackW
+ }
+
+ renderer.scrollTrack.Move(fyne.NewPos(trackX, tvPadY))
+ renderer.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight))
+
+ visibleLines := int(bodyHeight / tvLineH)
+ if totalLines <= visibleLines {
+ renderer.scrollTrack.FillColor = color.Transparent
+ renderer.scrollTrack.Refresh()
+ renderer.scrollThumb.FillColor = color.Transparent
+ renderer.scrollThumb.Refresh()
+ if renderer.thumbWidget != nil {
+ renderer.thumbWidget.Hide()
+ }
+ return
+ }
+
+ renderer.scrollTrack.FillColor = theme.Color(theme.ColorNameScrollBar)
+ renderer.scrollTrack.Refresh()
+
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbHeight := bodyHeight * ratio
+ if thumbHeight < tvScrollMinThumb {
+ thumbHeight = tvScrollMinThumb
+ }
+ scrollable := float32(totalLines - visibleLines)
+ thumbY := tvPadY + (float32(renderer.view.scrollOffset)/scrollable)*(bodyHeight-thumbHeight)
+
+ thumbW := float32(tvScrollW - 2)
+ thumbX := trackX + 1
+ if renderer.view.scrollbarHovered {
+ thumbW = trackW - 2
+ thumbX = trackX + 1
+ }
+
+ renderer.scrollThumb.Move(fyne.NewPos(thumbX, thumbY))
+ renderer.scrollThumb.Resize(fyne.NewSize(thumbW, thumbHeight))
+ renderer.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground)
+ renderer.scrollThumb.CornerRadius = thumbW / 2
+ renderer.scrollThumb.Refresh()
+
+ if renderer.thumbWidget != nil {
+ renderer.thumbWidget.rect.FillColor = color.Transparent
+ renderer.thumbWidget.Move(fyne.NewPos(thumbX, thumbY))
+ renderer.thumbWidget.Resize(fyne.NewSize(thumbW, thumbHeight))
+ renderer.thumbWidget.Show()
+ }
+}
+
+func selectionColor() color.Color {
+ themeColor := theme.Color(theme.ColorNameSelection)
+ if nrgba, ok := themeColor.(color.NRGBA); ok {
+ nrgba.A = 180
+ return nrgba
+ }
+ return themeColor
+}
diff --git a/internal/ui/widgets/textview_test.go b/internal/ui/widgets/textview_test.go
new file mode 100644
index 0000000..b88fbc9
--- /dev/null
+++ b/internal/ui/widgets/textview_test.go
@@ -0,0 +1,189 @@
+package widgets
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// ── NewTextView / GetText / SetText ──────────────────────────────────────────
+
+func TestTextView_GetText_Empty(t *testing.T) {
+ tv := NewTextView()
+ assert.Equal(t, "", tv.GetText())
+}
+
+func TestTextView_SetText_StoresRaw(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.rawFull = "hello world"
+ tv.mu.Unlock()
+
+ assert.Equal(t, "hello world", tv.GetText())
+}
+
+func TestTextView_SetText_ResetsScroll(t *testing.T) {
+ tv := NewTextView()
+ tv.scrollOffset = 10
+
+ tv.mu.Lock()
+ tv.rawFull = "new content"
+ tv.scrollOffset = 0
+ tv.scrollFrac = 0
+ tv.hasSelection = false
+ tv.mu.Unlock()
+
+ assert.Equal(t, 0, tv.scrollOffset)
+}
+
+func TestTextView_SetText_ResetsSelection(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.hasSelection = true
+ tv.selAnchorLine, tv.selAnchorCol = 1, 3
+ tv.rawFull = "new"
+ tv.hasSelection = false
+ tv.mu.Unlock()
+
+ assert.False(t, tv.hasSelection)
+}
+
+func TestTextView_SetText_Empty(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.rawFull = "something"
+ tv.mu.Unlock()
+
+ tv.mu.Lock()
+ tv.rawFull = ""
+ tv.mu.Unlock()
+
+ assert.Equal(t, "", tv.GetText())
+}
+
+// ── normaliseSelection ───────────────────────────────────────────────────────
+
+func TestTextView_NormaliseSelection_Forward(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 2
+ tv.selLine, tv.selCol = 1, 5
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestTextView_NormaliseSelection_Reversed(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 1, 5
+ tv.selLine, tv.selCol = 0, 2
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestTextView_NormaliseSelection_SamePosition(t *testing.T) {
+ tv := NewTextView()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 3
+ tv.selLine, tv.selCol = 0, 3
+
+ sl, sc, el, ec := tv.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 3, sc)
+ assert.Equal(t, 0, el)
+ assert.Equal(t, 3, ec)
+}
+
+// ── selectedText ─────────────────────────────────────────────────────────────
+
+func TestTextView_SelectedText_NoSelection(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "hello world"}}
+ tv.mu.Unlock()
+ tv.hasSelection = false
+
+ assert.Equal(t, "", tv.selectedText())
+}
+
+func TestTextView_SelectedText_SameLine(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "hello world"}}
+ tv.mu.Unlock()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 0
+ tv.selLine, tv.selCol = 0, 5
+ tv.hasSelection = true
+
+ assert.Equal(t, "hello", tv.selectedText())
+}
+
+func TestTextView_SelectedText_MultiLine(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{
+ {Raw: "line one"},
+ {Raw: "line two"},
+ }
+ tv.mu.Unlock()
+ tv.selAnchorLine, tv.selAnchorCol = 0, 5
+ tv.selLine, tv.selCol = 1, 4
+ tv.hasSelection = true
+
+ assert.Equal(t, "one\nline", tv.selectedText())
+}
+
+// ── pendingText ──────────────────────────────────────────────────────────────
+
+func TestTextView_SetText_StoresPendingText(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.pendingText = "pending content"
+ tv.rawFull = "pending content"
+ tv.mu.Unlock()
+
+ tv.mu.RLock()
+ pending := tv.pendingText
+ tv.mu.RUnlock()
+
+ assert.Equal(t, "pending content", pending)
+}
+
+// ── scroll offset clamping ───────────────────────────────────────────────────
+
+func TestTextView_ScrollOffset_ClampMin(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = []tvLine{{Raw: "line one"}, {Raw: "line two"}}
+ tv.mu.Unlock()
+ tv.scrollOffset = 0
+
+ // Simulate scroll up past top.
+ tv.scrollOffset -= 5
+ if tv.scrollOffset < 0 {
+ tv.scrollOffset = 0
+ }
+
+ assert.Equal(t, 0, tv.scrollOffset)
+}
+
+func TestTextView_ScrollOffset_ClampMax(t *testing.T) {
+ tv := NewTextView()
+ tv.mu.Lock()
+ tv.lines = make([]tvLine, 10)
+ tv.mu.Unlock()
+ tv.scrollOffset = 0
+
+ maxOffset := 10 - 3
+ tv.scrollOffset = 100
+ if tv.scrollOffset > maxOffset {
+ tv.scrollOffset = maxOffset
+ }
+
+ assert.Equal(t, maxOffset, tv.scrollOffset)
+}
diff --git a/internal/ui/widgets/textviewentry.go b/internal/ui/widgets/textviewentry.go
new file mode 100644
index 0000000..48f39d8
--- /dev/null
+++ b/internal/ui/widgets/textviewentry.go
@@ -0,0 +1,1589 @@
+package widgets
+
+import (
+ "image/color"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+)
+
+const tveTabWidth = 4 // spaces per tab
+
+// TextViewEntry is a syntax-highlighted, virtualised, editable text widget.
+// It never calls widget.Entry.SetText so large requests never block the UI.
+//
+// Thread-safety: all mutable state is guarded by mu. All methods that mutate
+// state are called from the Fyne UI goroutine (via TypedKey, TypedRune, etc.)
+// so in practice mu only protects against the blink goroutine reading
+// cursorVisible concurrently with the renderer. Visual cache reads/writes are
+// also coordinated through mu.
+type TextViewEntry struct {
+ widget.BaseWidget
+
+ // OnSend is called when the user presses Ctrl+S.
+ OnSend func()
+
+ mu sync.RWMutex
+ rawFull string
+
+ scrollOffset int
+ scrollFrac float32
+
+ selAnchorLine int
+ selAnchorCol int
+ selLine int
+ selCol int
+ hasSelection bool
+
+ cursorLine int
+ cursorCol int
+
+ undoStack []tveUndoEntry
+
+ shiftSelecting bool
+ shiftHeld bool
+
+ cursorVisible bool
+ blinkReset chan struct{} // send to reset blink timer; close to stop goroutine
+ blinkRunning bool
+
+ dragging bool // true while mouse drag is in progress — guards FocusLost
+ scrollbarHovered bool // true while mouse is over scrollbar — widens thumb
+
+ // Visual line cache. Invalidated by setting visualCacheRaw = "".
+ // Protected by mu — both raw snapshot and built result stored together so
+ // there is no TOCTOU window.
+ visualCache []tveVisualLine
+ visualCacheRaw string
+ visualCacheWidth int
+ visualCacheValid bool
+
+ win fyne.Window
+ rend *tveRenderer
+}
+
+type tveUndoEntry struct {
+ raw string
+ cursorLine int
+ cursorCol int
+}
+
+type tveVisualLine struct {
+ tokens []tvToken
+ raw string
+ logicalIdx int
+ colOffset int
+}
+
+func NewTextViewEntry() *TextViewEntry {
+ e := &TextViewEntry{}
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+func (e *TextViewEntry) SetWindow(w fyne.Window) { e.win = w }
+func (e *TextViewEntry) SetPlaceHolder(_ string) {}
+func (e *TextViewEntry) AcceptsTab() bool { return true }
+
+func (e *TextViewEntry) GetText() string {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ return e.rawFull
+}
+
+func (e *TextViewEntry) SetText(s string) {
+ e.mu.Lock()
+ e.rawFull = s
+ e.scrollOffset = 0
+ e.scrollFrac = 0
+ e.hasSelection = false
+ e.shiftSelecting = false
+ e.shiftHeld = false
+ e.cursorLine = 0
+ e.cursorCol = 0
+ e.undoStack = nil
+ e.visualCacheValid = false
+ e.mu.Unlock()
+ e.Refresh()
+}
+
+// splitRaw normalises line endings and splits into logical lines.
+func splitRaw(raw string) []string {
+ if raw == "" {
+ return []string{""}
+ }
+ raw = strings.ReplaceAll(raw, "\r\n", "\n")
+ raw = strings.ReplaceAll(raw, "\r", "\n")
+ return strings.Split(raw, "\n")
+}
+
+// logicalLines returns the current logical lines. Caller must not hold mu.
+func (e *TextViewEntry) logicalLines() []string {
+ e.mu.RLock()
+ raw := e.rawFull
+ e.mu.RUnlock()
+ return splitRaw(raw)
+}
+
+// visual returns cached visual lines, rebuilding under mu if stale.
+// This eliminates the TOCTOU race: raw snapshot and cache write happen in
+// the same critical section.
+func (e *TextViewEntry) visual() []tveVisualLine {
+ cpl := e.charsPerLine()
+
+ e.mu.Lock()
+ raw := e.rawFull
+ if e.visualCache != nil && e.visualCacheValid && e.visualCacheRaw == raw && e.visualCacheWidth == cpl {
+ cached := e.visualCache
+ e.mu.Unlock()
+ return cached
+ }
+ // Build while holding the lock so raw cannot change under us.
+ built := buildVisualLines(splitRaw(raw), cpl)
+ e.visualCache = built
+ e.visualCacheRaw = raw
+ e.visualCacheWidth = cpl
+ e.visualCacheValid = true
+
+ e.mu.Unlock()
+ return built
+}
+
+// commitLines writes mutated lines back and invalidates the visual cache.
+// Must be called from the UI goroutine (same as all edit methods).
+func (e *TextViewEntry) commitLines(lines []string) {
+ e.mu.Lock()
+ e.rawFull = strings.Join(lines, "\n")
+ e.visualCacheRaw = ""
+ e.visualCacheValid = false
+ e.mu.Unlock()
+}
+
+// charsPerLine computes the number of monospace characters that fit in the
+// available width. Safe to call without holding mu.
+func (e *TextViewEntry) charsPerLine() int {
+ if e.rend == nil {
+ return 80
+ }
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ availWidth := e.rend.size.Width - tvPadX*2 - tvScrollW
+ if availWidth < 1 {
+ return 80
+ }
+ n := int(availWidth / charWidth)
+ if n < 10 {
+ n = 10
+ }
+ return n
+}
+
+// buildVisualLines converts logical lines into wrapped visual lines.
+// Each logical line is tokenised once for correct colours on continuations.
+func buildVisualLines(logicalLines []string, charsPerLine int) []tveVisualLine {
+ if charsPerLine < 10 {
+ charsPerLine = 10
+ }
+ bodyStartLine := -1
+ for i, l := range logicalLines {
+ if l == "" && i > 0 {
+ bodyStartLine = i
+ break
+ }
+ }
+ contentType := "text"
+ if bodyStartLine >= 0 {
+ contentType = detectContentType(logicalLines)
+ }
+
+ var result []tveVisualLine
+
+ for logIdx, rawLine := range logicalLines {
+ inBody := bodyStartLine >= 0 && logIdx > bodyStartLine
+ runes := []rune(rawLine)
+
+ if len(runes) == 0 {
+ var tokens []tvToken
+ if !inBody {
+ tokens = tokeniseHTTPMeta(rawLine, logIdx == 0)
+ } else {
+ tokens = tokeniseBody(rawLine, contentType)
+ }
+ result = append(result, tveVisualLine{
+ tokens: tokens, raw: "", logicalIdx: logIdx, colOffset: 0,
+ })
+ continue
+ }
+
+ colOffset := 0
+ for colOffset < len(runes) {
+ end := colOffset + charsPerLine
+ if end > len(runes) {
+ end = len(runes)
+ }
+ chunk := string(runes[colOffset:end])
+ var chunkTokens []tvToken
+ if !inBody {
+ // Only the very first chunk of the first line is the HTTP start line.
+ chunkTokens = tokeniseHTTPMeta(chunk, logIdx == 0 && colOffset == 0)
+ } else {
+ chunkTokens = tokeniseBody(chunk, contentType)
+ }
+ result = append(result, tveVisualLine{
+ tokens: chunkTokens,
+ raw: chunk,
+ logicalIdx: logIdx,
+ colOffset: colOffset,
+ })
+ colOffset = end
+ }
+ }
+ return result
+}
+
+// logicalToVisual maps a logical (line, col) to the visual line index and
+// column within that visual line. Handles end-of-line and wrapped lines correctly.
+func logicalToVisual(visual []tveVisualLine, logicalLine, logicalCol int) (int, int) {
+ lastMatchV := -1
+ for v, vl := range visual {
+ if vl.logicalIdx != logicalLine {
+ continue
+ }
+ chunkLen := len([]rune(vl.raw))
+ chunkEnd := vl.colOffset + chunkLen
+ isLastChunk := v+1 >= len(visual) || visual[v+1].logicalIdx != logicalLine
+
+ if logicalCol >= vl.colOffset && logicalCol < chunkEnd {
+ return v, logicalCol - vl.colOffset
+ }
+ if isLastChunk && logicalCol >= chunkEnd {
+ return v, chunkLen
+ }
+ lastMatchV = v
+ }
+ if lastMatchV >= 0 {
+ vl := visual[lastMatchV]
+ return lastMatchV, len([]rune(vl.raw))
+ }
+ return 0, 0
+}
+
+func (e *TextViewEntry) pushUndo(prevRaw string, prevCursorLine, prevCursorCol int) {
+ if len(e.undoStack) >= 100 {
+ e.undoStack = e.undoStack[1:]
+ }
+ e.undoStack = append(e.undoStack, tveUndoEntry{
+ raw: prevRaw, cursorLine: prevCursorLine, cursorCol: prevCursorCol,
+ })
+}
+
+func (e *TextViewEntry) deleteSelection(lines []string) []string {
+ if !e.hasSelection {
+ return lines
+ }
+ startLine, startCol, endLine, endCol := e.normaliseSelection()
+ if !e.hasSelection {
+ return lines
+ }
+ if startLine >= len(lines) {
+ startLine = len(lines) - 1
+ }
+ if endLine >= len(lines) {
+ endLine = len(lines) - 1
+ }
+ if startLine == endLine {
+ runes := []rune(lines[startLine])
+ if startCol > len(runes) {
+ startCol = len(runes)
+ }
+ if endCol > len(runes) {
+ endCol = len(runes)
+ }
+ newRunes := append([]rune{}, runes[:startCol]...)
+ newRunes = append(newRunes, runes[endCol:]...)
+ lines[startLine] = string(newRunes)
+ } else {
+ startRunes := []rune(lines[startLine])
+ endRunes := []rune(lines[endLine])
+ if startCol > len(startRunes) {
+ startCol = len(startRunes)
+ }
+ if endCol > len(endRunes) {
+ endCol = len(endRunes)
+ }
+ merged := string(startRunes[:startCol]) + string(endRunes[endCol:])
+ newLines := make([]string, 0, len(lines)-(endLine-startLine))
+ newLines = append(newLines, lines[:startLine]...)
+ newLines = append(newLines, merged)
+ newLines = append(newLines, lines[endLine+1:]...)
+ lines = newLines
+ }
+ e.cursorLine = startLine
+ e.cursorCol = startCol
+ e.hasSelection = false
+ e.shiftSelecting = false
+ return lines
+}
+
+func (e *TextViewEntry) ensureCursorVisible() {
+ visible := e.visibleLineCount()
+ if visible <= 0 {
+ return
+ }
+ vLine, _ := logicalToVisual(e.visual(), e.cursorLine, e.cursorCol)
+ if vLine < e.scrollOffset {
+ e.scrollOffset = vLine
+ } else if vLine >= e.scrollOffset+visible {
+ e.scrollOffset = vLine - visible + 1
+ }
+}
+
+func (e *TextViewEntry) thumbBounds() (thumbY, thumbH float32, ok bool) {
+ if e.rend == nil {
+ return 0, 0, false
+ }
+ totalLines := len(e.visual())
+ bodyHeight := e.rend.size.Height - tvPadY*2
+ visibleLines := int(bodyHeight / tvLineH)
+ scrollable := totalLines - visibleLines
+ if scrollable <= 0 || bodyHeight <= 0 {
+ return 0, 0, false
+ }
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbH = bodyHeight * ratio
+ if thumbH < tvScrollMinThumb {
+ thumbH = tvScrollMinThumb
+ }
+ trackHeight := bodyHeight - thumbH
+ thumbY = tvPadY + (float32(e.scrollOffset)/float32(scrollable))*trackHeight
+ return thumbY, thumbH, true
+}
+
+// --------------------------------------------------------------------------
+// Blink — single long-lived goroutine, reset via channel on each keystroke/click.
+// --------------------------------------------------------------------------
+
+func (e *TextViewEntry) startBlink() {
+ e.stopBlink()
+ resetCh := make(chan struct{}, 1)
+ e.blinkReset = resetCh
+ e.blinkRunning = true
+ e.mu.Lock()
+ e.cursorVisible = true
+ e.mu.Unlock()
+ go func() {
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case _, ok := <-resetCh:
+ if !ok {
+ // Channel closed — widget destroyed or focus lost.
+ return
+ }
+ // Reset: make cursor visible and restart the ticker.
+ fyne.Do(func() {
+ e.mu.Lock()
+ e.cursorVisible = true
+ e.mu.Unlock()
+ })
+ ticker.Reset(500 * time.Millisecond)
+ case <-ticker.C:
+ fyne.Do(func() {
+ e.mu.Lock()
+ e.cursorVisible = !e.cursorVisible
+ e.mu.Unlock()
+ e.Refresh()
+ })
+ }
+ }
+ }()
+}
+
+func (e *TextViewEntry) stopBlink() {
+ if e.blinkReset != nil {
+ close(e.blinkReset)
+ e.blinkReset = nil
+ }
+ e.blinkRunning = false
+ e.mu.Lock()
+ e.cursorVisible = false
+ e.mu.Unlock()
+}
+
+// resetBlink makes the cursor immediately visible and resets the blink timer.
+// Non-blocking — uses a buffered channel so rapid keystrokes never block.
+func (e *TextViewEntry) resetBlink() {
+ if !e.blinkRunning {
+ return
+ }
+ select {
+ case e.blinkReset <- struct{}{}:
+ default:
+ // Already a reset pending — fine, cursor will be shown.
+ }
+}
+
+// --------------------------------------------------------------------------
+// Widget lifecycle
+// --------------------------------------------------------------------------
+
+func (e *TextViewEntry) CreateRenderer() fyne.WidgetRenderer {
+ e.stopBlink()
+ r := newTVERenderer(e)
+ e.rend = r
+ return r
+}
+
+func (e *TextViewEntry) Build() fyne.CanvasObject {
+ return container.NewStack(e, newTVEInputLayer(e))
+}
+
+// --------------------------------------------------------------------------
+// Input handling
+// --------------------------------------------------------------------------
+
+func (e *TextViewEntry) Scrolled(ev *fyne.ScrollEvent) {
+ totalLines := len(e.visual())
+ visible := e.visibleLineCount()
+ maxOffset := totalLines - visible
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ e.scrollFrac -= ev.Scrolled.DY / tvLineH
+ rows := int(e.scrollFrac)
+ e.scrollFrac -= float32(rows)
+ if rows == 0 {
+ if ev.Scrolled.DY < 0 {
+ rows = 1
+ } else if ev.Scrolled.DY > 0 {
+ rows = -1
+ }
+ }
+ e.scrollOffset += rows
+ if e.scrollOffset < 0 {
+ e.scrollOffset = 0
+ }
+ if e.scrollOffset > maxOffset {
+ e.scrollOffset = maxOffset
+ }
+ e.Refresh()
+}
+
+func (e *TextViewEntry) visibleLineCount() int {
+ if e.rend == nil {
+ return 0
+ }
+ availHeight := e.rend.size.Height - tvPadY*2
+ if availHeight < 0 {
+ return 0
+ }
+ return int(availHeight/tvLineH) + 1
+}
+
+func (e *TextViewEntry) posFromPoint(point fyne.Position) (line, col int) {
+ visual := e.visual()
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ vLineIdx := e.scrollOffset + int((point.Y-tvPadY)/tvLineH)
+ if vLineIdx < 0 {
+ vLineIdx = 0
+ }
+ if vLineIdx >= len(visual) {
+ vLineIdx = len(visual) - 1
+ }
+ if vLineIdx < 0 {
+ return 0, 0
+ }
+ vl := visual[vLineIdx]
+ runes := []rune(vl.raw)
+ vCol := int((point.X - tvPadX) / charWidth)
+ if vCol < 0 {
+ vCol = 0
+ }
+ if vCol > len(runes) {
+ vCol = len(runes)
+ }
+ return vl.logicalIdx, vl.colOffset + vCol
+}
+
+func (e *TextViewEntry) normaliseSelection() (int, int, int, int) {
+ al, ac := e.selAnchorLine, e.selAnchorCol
+ cl, cc := e.selLine, e.selCol
+ if al > cl || (al == cl && ac > cc) {
+ return cl, cc, al, ac
+ }
+ return al, ac, cl, cc
+}
+
+func (e *TextViewEntry) selectedText() string {
+ if !e.hasSelection {
+ return ""
+ }
+ lines := e.logicalLines()
+ startLine, startCol, endLine, endCol := e.normaliseSelection()
+ if startLine == endLine {
+ runes := []rune(lines[startLine])
+ if startCol > len(runes) {
+ startCol = len(runes)
+ }
+ if endCol > len(runes) {
+ endCol = len(runes)
+ }
+ return string(runes[startCol:endCol])
+ }
+ var b strings.Builder
+ for i := startLine; i <= endLine; i++ {
+ if i >= len(lines) {
+ break
+ }
+ runes := []rune(lines[i])
+ lo, hi := 0, len(runes)
+ if i == startLine {
+ lo = startCol
+ }
+ if i == endLine {
+ hi = endCol
+ if hi > len(runes) {
+ hi = len(runes)
+ }
+ }
+ if lo > len(runes) {
+ lo = len(runes)
+ }
+ b.WriteString(string(runes[lo:hi]))
+ if i < endLine {
+ b.WriteByte('\n')
+ }
+ }
+ return b.String()
+}
+
+// Dragged is implemented directly on TextViewEntry so Fyne's hit-test finds it
+// before any parent split container — same pattern as widget.Entry.
+func (e *TextViewEntry) Dragged(ev *fyne.DragEvent) {
+ if e.rend != nil && ev.Position.X >= e.rend.size.Width-tvScrollW {
+ return
+ }
+ line, col := e.posFromPoint(ev.Position)
+ e.selLine, e.selCol = line, col
+ e.cursorLine, e.cursorCol = line, col
+ e.hasSelection = !(e.selAnchorLine == line && e.selAnchorCol == col)
+ e.Refresh()
+}
+
+func (e *TextViewEntry) DragEnd() {}
+
+func (e *TextViewEntry) TypedRune(r rune) {
+ lines := e.logicalLines()
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ lines = e.deleteSelection(lines)
+ }
+ cl := e.cursorLine
+ cc := e.cursorCol
+ if cl >= len(lines) {
+ cl = len(lines) - 1
+ }
+ runes := []rune(lines[cl])
+ if cc > len(runes) {
+ cc = len(runes)
+ }
+ newRunes := make([]rune, 0, len(runes)+1)
+ newRunes = append(newRunes, runes[:cc]...)
+ newRunes = append(newRunes, r)
+ newRunes = append(newRunes, runes[cc:]...)
+ lines[cl] = string(newRunes)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.mu.Lock()
+ e.cursorLine = cl
+ e.cursorCol = cc + 1
+ e.mu.Unlock()
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+}
+
+func (e *TextViewEntry) TypedKey(ev *fyne.KeyEvent) {
+ lines := e.logicalLines()
+ cl := e.cursorLine
+ cc := e.cursorCol
+ if cl >= len(lines) {
+ cl = len(lines) - 1
+ }
+ if cl < 0 {
+ cl = 0
+ }
+ runes := []rune(lines[cl])
+ if cc > len(runes) {
+ cc = len(runes)
+ }
+
+ isShift := e.shiftHeld
+
+ beginShift := func() {
+ if !e.shiftSelecting {
+ e.shiftSelecting = true
+ e.selAnchorLine = cl
+ e.selAnchorCol = cc
+ }
+ }
+ updateShift := func() {
+ e.selLine = e.cursorLine
+ e.selCol = e.cursorCol
+ e.hasSelection = !(e.selAnchorLine == e.selLine && e.selAnchorCol == e.selCol)
+ }
+ cancelShift := func() {
+ e.shiftSelecting = false
+ e.shiftHeld = false
+ e.hasSelection = false
+ }
+
+ switch ev.Name {
+ case fyne.KeyBackspace:
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ lines = e.deleteSelection(lines)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ } else if cc > 0 {
+ newRunes := append([]rune{}, runes[:cc-1]...)
+ newRunes = append(newRunes, runes[cc:]...)
+ lines[cl] = string(newRunes)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.mu.Lock()
+ e.cursorCol = cc - 1
+ e.mu.Unlock()
+ } else if cl > 0 {
+ prevRunes := []rune(lines[cl-1])
+ merged := string(prevRunes) + string(runes)
+ newLines := append([]string{}, lines[:cl-1]...)
+ newLines = append(newLines, merged)
+ newLines = append(newLines, lines[cl+1:]...)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(newLines)
+ e.mu.Lock()
+ e.cursorLine = cl - 1
+ e.cursorCol = len(prevRunes)
+ e.mu.Unlock()
+ }
+ cancelShift()
+
+ case fyne.KeyDelete:
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ lines = e.deleteSelection(lines)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ } else if cc < len(runes) {
+ newRunes := append([]rune{}, runes[:cc]...)
+ newRunes = append(newRunes, runes[cc+1:]...)
+ lines[cl] = string(newRunes)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ } else if cl < len(lines)-1 {
+ merged := string(runes) + lines[cl+1]
+ newLines := append([]string{}, lines[:cl]...)
+ newLines = append(newLines, merged)
+ newLines = append(newLines, lines[cl+2:]...)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(newLines)
+ }
+ cancelShift()
+
+ case fyne.KeyReturn, fyne.KeyEnter:
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ lines = e.deleteSelection(lines)
+ cl = e.cursorLine
+ cc = e.cursorCol
+ runes = []rune(lines[cl])
+ }
+ before := string(runes[:cc])
+ after := string(runes[cc:])
+ newLines := make([]string, 0, len(lines)+1)
+ newLines = append(newLines, lines[:cl]...)
+ newLines = append(newLines, before, after)
+ newLines = append(newLines, lines[cl+1:]...)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(newLines)
+ e.mu.Lock()
+ e.cursorLine = cl + 1
+ e.cursorCol = 0
+ e.mu.Unlock()
+ cancelShift()
+
+ case fyne.KeyTab:
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ startLine, selStartCol, endLine, selEndCol := e.normaliseSelection()
+ indent := strings.Repeat(" ", tveTabWidth)
+ for i := startLine; i <= endLine; i++ {
+ lines[i] = indent + lines[i]
+ }
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.selAnchorLine = startLine
+ if selStartCol > 0 {
+ e.selAnchorCol = selStartCol + tveTabWidth
+ } else {
+ e.selAnchorCol = 0
+ }
+ e.selLine = endLine
+ e.selCol = selEndCol + tveTabWidth
+ // Preserve cursor at whichever end it was before.
+ if prevCL == startLine {
+ e.cursorLine = startLine
+ e.cursorCol = prevCC + tveTabWidth
+ } else {
+ e.cursorLine = endLine
+ e.cursorCol = selEndCol + tveTabWidth
+ }
+ } else {
+ indent := strings.Repeat(" ", tveTabWidth)
+ newRunes := make([]rune, 0, len(runes)+tveTabWidth)
+ newRunes = append(newRunes, runes[:cc]...)
+ newRunes = append(newRunes, []rune(indent)...)
+ newRunes = append(newRunes, runes[cc:]...)
+ lines[cl] = string(newRunes)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.mu.Lock()
+ e.cursorCol = cc + tveTabWidth
+ e.mu.Unlock()
+ }
+
+ case fyne.KeyLeft:
+ if isShift {
+ beginShift()
+ } else if e.hasSelection {
+ startLine, startCol, _, _ := e.normaliseSelection()
+ e.mu.Lock()
+ e.cursorLine = startLine
+ e.cursorCol = startCol
+ e.mu.Unlock()
+ cancelShift()
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+ return
+ }
+ if cc > 0 {
+ e.mu.Lock()
+ e.cursorCol--
+ e.mu.Unlock()
+ } else if cl > 0 {
+ e.mu.Lock()
+ e.cursorLine--
+ e.cursorCol = len([]rune(lines[e.cursorLine]))
+ e.mu.Unlock()
+ }
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyRight:
+ if isShift {
+ beginShift()
+ } else if e.hasSelection {
+ _, _, endLine, endCol := e.normaliseSelection()
+ e.mu.Lock()
+ e.cursorLine = endLine
+ e.cursorCol = endCol
+ e.mu.Unlock()
+ cancelShift()
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+ return
+ }
+ if cc < len(runes) {
+ e.mu.Lock()
+ e.cursorCol++
+ e.mu.Unlock()
+ } else if cl < len(lines)-1 {
+ e.mu.Lock()
+ e.cursorLine++
+ e.cursorCol = 0
+ e.mu.Unlock()
+ }
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyUp:
+ if isShift {
+ beginShift()
+ } else {
+ cancelShift()
+ }
+ if cl > 0 {
+ e.mu.Lock()
+ e.cursorLine--
+ prevLen := len([]rune(lines[e.cursorLine]))
+ if e.cursorCol > prevLen {
+ e.cursorCol = prevLen
+ }
+ e.mu.Unlock()
+ }
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyDown:
+ if isShift {
+ beginShift()
+ } else {
+ cancelShift()
+ }
+ if cl < len(lines)-1 {
+ e.mu.Lock()
+ e.cursorLine++
+ nextLen := len([]rune(lines[e.cursorLine]))
+ if e.cursorCol > nextLen {
+ e.cursorCol = nextLen
+ }
+ e.mu.Unlock()
+ }
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyHome:
+ if isShift {
+ beginShift()
+ } else {
+ cancelShift()
+ }
+ e.mu.Lock()
+ e.cursorCol = 0
+ e.mu.Unlock()
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyEnd:
+ if isShift {
+ beginShift()
+ } else {
+ cancelShift()
+ }
+ e.mu.Lock()
+ e.cursorCol = len(runes)
+ e.mu.Unlock()
+ if isShift {
+ updateShift()
+ }
+
+ case fyne.KeyPageUp:
+ visible := e.visibleLineCount()
+ visual := e.visual()
+ curVLine, _ := logicalToVisual(visual, e.cursorLine, e.cursorCol)
+ targetVLine := curVLine - visible
+ if targetVLine < 0 {
+ targetVLine = 0
+ }
+ vl := visual[targetVLine]
+ e.mu.Lock()
+ e.cursorLine = vl.logicalIdx
+ e.cursorCol = vl.colOffset
+ e.mu.Unlock()
+ cancelShift()
+
+ case fyne.KeyPageDown:
+ visible := e.visibleLineCount()
+ visual := e.visual()
+ curVLine, _ := logicalToVisual(visual, e.cursorLine, e.cursorCol)
+ targetVLine := curVLine + visible
+ if targetVLine >= len(visual) {
+ targetVLine = len(visual) - 1
+ }
+ vl := visual[targetVLine]
+ e.mu.Lock()
+ e.cursorLine = vl.logicalIdx
+ e.cursorCol = vl.colOffset
+ e.mu.Unlock()
+ cancelShift()
+ }
+
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+}
+
+func (e *TextViewEntry) TypedShortcut(s fyne.Shortcut) {
+ if cs, ok := s.(*desktop.CustomShortcut); ok {
+ if cs.KeyName == fyne.KeyS && cs.Modifier == fyne.KeyModifierControl {
+ if e.OnSend != nil {
+ e.OnSend()
+ }
+ return
+ }
+ }
+
+ switch s.(type) {
+ case *fyne.ShortcutUndo:
+ e.mu.Lock()
+ if len(e.undoStack) > 0 {
+ undo := e.undoStack[len(e.undoStack)-1]
+ e.undoStack = e.undoStack[:len(e.undoStack)-1]
+ e.rawFull = undo.raw
+ e.visualCacheValid = false
+ e.cursorLine = undo.cursorLine
+ e.cursorCol = undo.cursorCol
+ e.hasSelection = false
+ e.shiftSelecting = false
+ e.shiftHeld = false
+ }
+ e.mu.Unlock()
+ e.resetBlink()
+ e.Refresh()
+
+ case *fyne.ShortcutSelectAll:
+ lines := e.logicalLines()
+ totalLines := len(lines)
+ e.selAnchorLine, e.selAnchorCol = 0, 0
+ e.selLine = totalLines - 1
+ if e.selLine < 0 {
+ e.selLine = 0
+ }
+ e.selCol = len([]rune(lines[e.selLine]))
+ e.hasSelection = true
+ e.Refresh()
+
+ case *fyne.ShortcutCopy:
+ if e.win != nil && e.hasSelection {
+ e.win.Clipboard().SetContent(e.selectedText())
+ }
+
+ case *fyne.ShortcutCut:
+ if e.win == nil || !e.hasSelection {
+ return
+ }
+ e.win.Clipboard().SetContent(e.selectedText())
+ lines := e.logicalLines()
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ lines = e.deleteSelection(lines)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+
+ case *fyne.ShortcutPaste:
+ if e.win == nil {
+ return
+ }
+ text := e.win.Clipboard().Content()
+ if text == "" {
+ return
+ }
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+ text = strings.ReplaceAll(text, "\r", "\n")
+ lines := e.logicalLines()
+ prevRaw := strings.Join(lines, "\n")
+ prevCL, prevCC := e.cursorLine, e.cursorCol
+ if e.hasSelection {
+ lines = e.deleteSelection(lines)
+ }
+ cl := e.cursorLine
+ cc := e.cursorCol
+ if cl >= len(lines) {
+ cl = len(lines) - 1
+ }
+ runes := []rune(lines[cl])
+ if cc > len(runes) {
+ cc = len(runes)
+ }
+ pasteLines := strings.Split(text, "\n")
+ if len(pasteLines) == 1 {
+ newRunes := make([]rune, 0, len(runes)+len([]rune(text)))
+ newRunes = append(newRunes, runes[:cc]...)
+ newRunes = append(newRunes, []rune(text)...)
+ newRunes = append(newRunes, runes[cc:]...)
+ lines[cl] = string(newRunes)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(lines)
+ e.mu.Lock()
+ e.cursorLine = cl
+ e.cursorCol = cc + len([]rune(pasteLines[0]))
+ e.mu.Unlock()
+ } else {
+ firstPart := string(runes[:cc]) + pasteLines[0]
+ lastPart := pasteLines[len(pasteLines)-1] + string(runes[cc:])
+ newLines := make([]string, 0, len(lines)+len(pasteLines)-1)
+ newLines = append(newLines, lines[:cl]...)
+ newLines = append(newLines, firstPart)
+ newLines = append(newLines, pasteLines[1:len(pasteLines)-1]...)
+ newLines = append(newLines, lastPart)
+ newLines = append(newLines, lines[cl+1:]...)
+ e.pushUndo(prevRaw, prevCL, prevCC)
+ e.commitLines(newLines)
+ e.mu.Lock()
+ e.cursorLine = cl + len(pasteLines) - 1
+ e.cursorCol = len([]rune(pasteLines[len(pasteLines)-1]))
+ e.mu.Unlock()
+ }
+ e.ensureCursorVisible()
+ e.resetBlink()
+ e.Refresh()
+ }
+}
+
+func (e *TextViewEntry) FocusGained() {
+ e.startBlink()
+ e.Refresh()
+}
+
+func (e *TextViewEntry) FocusLost() {
+ e.stopBlink()
+ e.shiftHeld = false
+ // Only clear selection state if we are not mid-drag. When dragging outside
+ // the window Fyne fires FocusLost but the user is still interacting.
+ if !e.dragging {
+ e.shiftSelecting = false
+ }
+ e.Refresh()
+}
+
+func (e *TextViewEntry) KeyDown(ev *fyne.KeyEvent) {
+ if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight {
+ e.shiftHeld = true
+ }
+}
+
+func (e *TextViewEntry) KeyUp(ev *fyne.KeyEvent) {
+ if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight {
+ e.shiftHeld = false
+ e.shiftSelecting = false
+ }
+}
+
+// --------------------------------------------------------------------------
+// Renderer
+// --------------------------------------------------------------------------
+
+type tveRenderer struct {
+ entry *TextViewEntry
+ size fyne.Size
+
+ selBgs []*canvas.Rectangle
+ tokenSlots [][]*canvas.Text
+ cursorRect *canvas.Rectangle
+ slots int
+
+ scrollTrack *canvas.Rectangle
+ scrollThumb *canvas.Rectangle
+
+ lastWidth float32 // tracks width to invalidate visual cache on resize
+}
+
+func newTVERenderer(entry *TextViewEntry) *tveRenderer {
+ r := &tveRenderer{entry: entry}
+ r.scrollTrack = canvas.NewRectangle(color.Transparent)
+ r.scrollThumb = canvas.NewRectangle(color.Transparent)
+ r.cursorRect = canvas.NewRectangle(color.Transparent)
+ return r
+}
+
+// Destroy is called when the widget is removed. Stop the blink goroutine.
+func (r *tveRenderer) Destroy() {
+ r.entry.stopBlink()
+}
+
+func (r *tveRenderer) Objects() []fyne.CanvasObject {
+ total := 3 + len(r.selBgs) + len(r.tokenSlots)*tvMaxTokensPerLine
+ objs := make([]fyne.CanvasObject, 0, total)
+ objs = append(objs, r.scrollTrack, r.scrollThumb)
+ for _, selBg := range r.selBgs {
+ objs = append(objs, selBg)
+ }
+ for _, slot := range r.tokenSlots {
+ for _, t := range slot {
+ objs = append(objs, t)
+ }
+ }
+ objs = append(objs, r.cursorRect)
+ return objs
+}
+
+func (r *tveRenderer) MinSize() fyne.Size {
+ return fyne.NewSize(200, tvLineH*3+tvPadY*2)
+}
+
+func (r *tveRenderer) Layout(size fyne.Size) {
+ r.size = size
+ r.checkWrapWidth()
+ r.growSlots()
+ r.layoutContent()
+}
+
+func (r *tveRenderer) Refresh() {
+ r.checkWrapWidth()
+ r.growSlots()
+ r.layoutContent()
+ canvas.Refresh(r.entry)
+}
+
+func (r *tveRenderer) checkWrapWidth() {
+ availWidth := r.size.Width - tvPadX*2 - tvScrollW
+ if availWidth < 1 {
+ return
+ }
+ if r.lastWidth != availWidth {
+ r.lastWidth = availWidth
+ // Invalidate visual cache so wrapping is recomputed at new width.
+ r.entry.mu.Lock()
+ r.entry.visualCacheRaw = ""
+ r.entry.mu.Unlock()
+ }
+}
+
+func (r *tveRenderer) growSlots() {
+ bodyHeight := r.size.Height - tvPadY*2
+ if bodyHeight < 0 {
+ bodyHeight = 0
+ }
+ needed := int(bodyHeight/tvLineH) + 2
+ for r.slots < needed {
+ selBg := canvas.NewRectangle(color.Transparent)
+ r.selBgs = append(r.selBgs, selBg)
+ tokenSlot := make([]*canvas.Text, tvMaxTokensPerLine)
+ for i := range tokenSlot {
+ t := canvas.NewText("", color.White)
+ t.TextStyle = fyne.TextStyle{Monospace: true}
+ t.TextSize = theme.TextSize()
+ tokenSlot[i] = t
+ }
+ r.tokenSlots = append(r.tokenSlots, tokenSlot)
+ r.slots++
+ }
+ for slot := needed; slot < r.slots; slot++ {
+ r.selBgs[slot].Hide()
+ for _, t := range r.tokenSlots[slot] {
+ t.Hide()
+ }
+ }
+}
+
+func (r *tveRenderer) visibleLines() int {
+ bodyHeight := r.size.Height - tvPadY*2
+ if bodyHeight < 0 {
+ return 0
+ }
+ return int(bodyHeight/tvLineH) + 1
+}
+
+func (r *tveRenderer) layoutContent() {
+ entry := r.entry
+ visual := entry.visual()
+ totalLines := len(visual)
+
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+
+ entry.mu.Lock()
+ scrollOffset := entry.scrollOffset
+ hasSelection := entry.hasSelection
+ cursorLogLine := entry.cursorLine
+ cursorLogCol := entry.cursorCol
+ cursorVisible := entry.cursorVisible
+ var selStartLog, selStartCol, selEndLog, selEndCol int
+ if hasSelection {
+ selStartLog, selStartCol, selEndLog, selEndCol = entry.normaliseSelection()
+ }
+ entry.mu.Unlock()
+
+ visible := r.visibleLines()
+ maxOffset := totalLines - visible
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ if scrollOffset > maxOffset {
+ scrollOffset = maxOffset
+ entry.mu.Lock()
+ entry.scrollOffset = scrollOffset
+ entry.mu.Unlock()
+ }
+
+ needed := int((r.size.Height-tvPadY*2)/tvLineH) + 2
+ cursorVLine, cursorVCol := logicalToVisual(visual, cursorLogLine, cursorLogCol)
+ cursorDrawn := false
+ yPos := tvPadY
+
+ for slot := 0; slot < needed && slot < r.slots; slot++ {
+ lineIdx := scrollOffset + slot
+ selBg := r.selBgs[slot]
+
+ if lineIdx >= totalLines {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ for _, t := range r.tokenSlots[slot] {
+ t.Hide()
+ }
+ yPos += tvLineH
+ continue
+ }
+
+ vl := visual[lineIdx]
+
+ // Selection highlight. Fix 5: use strict < chunkEnd for start boundary.
+ if hasSelection {
+ vSelStart, vSelEnd := -1, -1
+ chunkStart := vl.colOffset
+ chunkEnd := vl.colOffset + len([]rune(vl.raw))
+ isLastChunk := lineIdx+1 >= len(visual) || visual[lineIdx+1].logicalIdx != vl.logicalIdx
+
+ switch {
+ case vl.logicalIdx > selStartLog && vl.logicalIdx < selEndLog:
+ vSelStart = 0
+ vSelEnd = len([]rune(vl.raw))
+ case vl.logicalIdx == selStartLog && vl.logicalIdx == selEndLog:
+ if selStartCol < chunkEnd && selEndCol > chunkStart {
+ vSelStart = selStartCol - chunkStart
+ vSelEnd = selEndCol - chunkStart
+ }
+ case vl.logicalIdx == selStartLog:
+ if selStartCol < chunkEnd {
+ vSelStart = selStartCol - chunkStart
+ vSelEnd = len([]rune(vl.raw))
+ // Extend one extra char width for visual continuity if not last chunk.
+ if !isLastChunk {
+ vSelEnd++
+ }
+ }
+ case vl.logicalIdx == selEndLog:
+ if selEndCol > chunkStart {
+ vSelStart = 0
+ vSelEnd = selEndCol - chunkStart
+ }
+ }
+
+ if vSelStart < 0 {
+ vSelStart = 0
+ }
+ lineLen := len([]rune(vl.raw))
+ if vSelEnd > lineLen {
+ vSelEnd = lineLen
+ }
+
+ if vSelStart >= 0 && vSelEnd > vSelStart {
+ selBg.Move(fyne.NewPos(tvPadX+float32(vSelStart)*charWidth, yPos))
+ selBg.Resize(fyne.NewSize(float32(vSelEnd-vSelStart)*charWidth, tvLineH))
+ selBg.FillColor = selectionColor()
+ selBg.Refresh()
+ selBg.Show()
+ } else {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ }
+ } else {
+ selBg.FillColor = color.Transparent
+ selBg.Resize(fyne.NewSize(0, 0))
+ selBg.Refresh()
+ selBg.Hide()
+ }
+
+ // Draw tokens.
+ xPos := tvPadX
+ tokenIdx := 0
+ for _, token := range vl.tokens {
+ if tokenIdx >= tvMaxTokensPerLine {
+ break
+ }
+ if token.Text == "" {
+ continue
+ }
+ t := r.tokenSlots[slot][tokenIdx]
+ t.Text = token.Text
+ t.Color = tvColor(token.Kind)
+ t.TextStyle = fyne.TextStyle{Monospace: true}
+ t.TextSize = theme.TextSize()
+ t.Move(fyne.NewPos(xPos, yPos))
+ t.Refresh()
+ t.Show()
+ xPos += fyne.MeasureText(token.Text, theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ tokenIdx++
+ }
+ for ; tokenIdx < tvMaxTokensPerLine; tokenIdx++ {
+ r.tokenSlots[slot][tokenIdx].Hide()
+ }
+
+ // Draw cursor.
+ if lineIdx == cursorVLine && cursorVisible {
+ cursorX := tvPadX + float32(cursorVCol)*charWidth
+ r.cursorRect.Move(fyne.NewPos(cursorX, yPos+2))
+ r.cursorRect.Resize(fyne.NewSize(2, tvLineH-4))
+ r.cursorRect.FillColor = theme.Color(theme.ColorNameForeground)
+ r.cursorRect.Refresh()
+ r.cursorRect.Show()
+ cursorDrawn = true
+ }
+
+ yPos += tvLineH
+ }
+
+ if !cursorDrawn {
+ r.cursorRect.FillColor = color.Transparent
+ r.cursorRect.Resize(fyne.NewSize(0, 0))
+ r.cursorRect.Refresh()
+ r.cursorRect.Hide()
+ }
+
+ r.layoutScrollbar(totalLines)
+}
+
+func (r *tveRenderer) layoutScrollbar(totalLines int) {
+ bodyHeight := r.size.Height - tvPadY*2
+ trackW := float32(tvScrollW)
+ trackX := r.size.Width - trackW
+ if r.entry.scrollbarHovered {
+ trackW = tvScrollW + 6
+ trackX = r.size.Width - trackW
+ }
+ r.scrollTrack.Move(fyne.NewPos(trackX, tvPadY))
+ r.scrollTrack.Resize(fyne.NewSize(trackW, bodyHeight))
+
+ visibleLines := int(bodyHeight / tvLineH)
+ if totalLines <= visibleLines {
+ r.scrollTrack.FillColor = color.Transparent
+ r.scrollTrack.Refresh()
+ r.scrollThumb.FillColor = color.Transparent
+ r.scrollThumb.Refresh()
+ return
+ }
+
+ r.scrollTrack.FillColor = theme.Color(theme.ColorNameScrollBar)
+ r.scrollTrack.Refresh()
+
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbHeight := bodyHeight * ratio
+ if thumbHeight < tvScrollMinThumb {
+ thumbHeight = tvScrollMinThumb
+ }
+ scrollable := float32(totalLines - visibleLines)
+ thumbY := tvPadY + (float32(r.entry.scrollOffset)/scrollable)*(bodyHeight-thumbHeight)
+
+ thumbW := float32(tvScrollW - 2)
+ thumbX := trackX + 1
+ if r.entry.scrollbarHovered {
+ thumbW = trackW - 2
+ thumbX = trackX + 1
+ }
+ r.scrollThumb.Move(fyne.NewPos(thumbX, thumbY))
+ r.scrollThumb.Resize(fyne.NewSize(thumbW, thumbHeight))
+ r.scrollThumb.FillColor = theme.Color(theme.ColorNameForeground)
+ r.scrollThumb.CornerRadius = thumbW / 2
+ r.scrollThumb.Refresh()
+}
+
+// --------------------------------------------------------------------------
+// Input layer
+// --------------------------------------------------------------------------
+
+type tveInputLayer struct {
+ widget.BaseWidget
+ entry *TextViewEntry
+ draggingThumb bool
+ draggingText bool
+}
+
+func newTVEInputLayer(entry *TextViewEntry) *tveInputLayer {
+ layer := &tveInputLayer{entry: entry}
+ layer.ExtendBaseWidget(layer)
+ return layer
+}
+
+func (layer *tveInputLayer) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(canvas.NewRectangle(color.Transparent))
+}
+
+func (layer *tveInputLayer) Scrolled(ev *fyne.ScrollEvent) { layer.entry.Scrolled(ev) }
+
+func (layer *tveInputLayer) inScrollbarArea(xPos float32) bool {
+ if layer.entry.rend == nil {
+ return false
+ }
+ return xPos >= layer.entry.rend.size.Width-tvScrollW
+}
+
+func (layer *tveInputLayer) inThumb(xPos, yPos float32) bool {
+ if !layer.inScrollbarArea(xPos) {
+ return false
+ }
+ thumbY, thumbH, ok := layer.entry.thumbBounds()
+ if !ok {
+ return false
+ }
+ return yPos >= thumbY && yPos <= thumbY+thumbH
+}
+
+func (layer *tveInputLayer) MouseDown(ev *desktop.MouseEvent) {
+ if ev.Button != desktop.MouseButtonPrimary {
+ return
+ }
+ layer.draggingThumb = layer.inThumb(ev.Position.X, ev.Position.Y)
+ if layer.draggingThumb {
+ return
+ }
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ entry := layer.entry
+ line, col := entry.posFromPoint(ev.Position)
+ entry.selAnchorLine, entry.selAnchorCol = line, col
+ entry.selLine, entry.selCol = line, col
+ entry.cursorLine, entry.cursorCol = line, col
+ entry.hasSelection = false
+ entry.shiftSelecting = false
+ entry.shiftHeld = false
+ entry.dragging = false
+ entry.resetBlink()
+ entry.Refresh()
+ if entry.win != nil {
+ entry.win.Canvas().Focus(entry)
+ }
+}
+
+func (layer *tveInputLayer) MouseUp(_ *desktop.MouseEvent) {
+ layer.draggingThumb = false
+ layer.draggingText = false
+ layer.entry.dragging = false
+}
+
+func (layer *tveInputLayer) Tapped(_ *fyne.PointEvent) {}
+
+func (layer *tveInputLayer) DoubleTapped(ev *fyne.PointEvent) {
+ if layer.inScrollbarArea(ev.Position.X) {
+ return
+ }
+ entry := layer.entry
+ line, col := entry.posFromPoint(ev.Position)
+ lines := entry.logicalLines()
+ if line >= len(lines) {
+ return
+ }
+ runes := []rune(lines[line])
+ if len(runes) == 0 {
+ return
+ }
+ if col >= len(runes) {
+ col = len(runes) - 1
+ }
+ wordStart := col
+ for wordStart > 0 && tveIsWordRune(runes[wordStart-1]) {
+ wordStart--
+ }
+ wordEnd := col
+ for wordEnd < len(runes) && tveIsWordRune(runes[wordEnd]) {
+ wordEnd++
+ }
+ entry.selAnchorLine, entry.selAnchorCol = line, wordStart
+ entry.selLine, entry.selCol = line, wordEnd
+ entry.cursorLine, entry.cursorCol = line, wordEnd
+ entry.hasSelection = wordStart != wordEnd
+ entry.Refresh()
+}
+
+func (layer *tveInputLayer) dragScrollbar(ev *fyne.DragEvent) {
+ entry := layer.entry
+ if entry.rend == nil {
+ return
+ }
+ totalLines := len(entry.visual())
+ if totalLines == 0 {
+ return
+ }
+ bodyHeight := entry.rend.size.Height - tvPadY*2
+ visibleLines := int(bodyHeight / tvLineH)
+ scrollable := totalLines - visibleLines
+ if scrollable <= 0 {
+ return
+ }
+ ratio := float32(visibleLines) / float32(totalLines)
+ thumbHeight := bodyHeight * ratio
+ if thumbHeight < tvScrollMinThumb {
+ thumbHeight = tvScrollMinThumb
+ }
+ trackHeight := bodyHeight - thumbHeight
+ if trackHeight <= 0 {
+ return
+ }
+ newThumbTop := ev.Position.Y - tvPadY - thumbHeight/2
+ if newThumbTop < 0 {
+ newThumbTop = 0
+ }
+ if newThumbTop > trackHeight {
+ newThumbTop = trackHeight
+ }
+ entry.scrollOffset = int((newThumbTop / trackHeight) * float32(scrollable))
+ entry.Refresh()
+}
+
+func (layer *tveInputLayer) Dragged(ev *fyne.DragEvent) {
+ if layer.draggingThumb {
+ layer.dragScrollbar(ev)
+ return
+ }
+ layer.draggingText = true
+ layer.entry.dragging = true
+ layer.entry.Dragged(ev)
+}
+
+func (layer *tveInputLayer) DragEnd() {
+ wasDraggingText := layer.draggingText
+ layer.draggingThumb = false
+ layer.draggingText = false
+ layer.entry.dragging = false
+ if wasDraggingText && layer.entry.win != nil {
+ layer.entry.win.Canvas().Focus(layer.entry)
+ layer.entry.resetBlink()
+ }
+}
+
+func (layer *tveInputLayer) MouseIn(_ *desktop.MouseEvent) {}
+
+func (layer *tveInputLayer) MouseOut() {
+ if layer.entry.scrollbarHovered {
+ layer.entry.scrollbarHovered = false
+ layer.entry.Refresh()
+ }
+}
+
+func (layer *tveInputLayer) MouseMoved(ev *desktop.MouseEvent) {
+ inScrollbar := layer.inScrollbarArea(ev.Position.X)
+ if inScrollbar != layer.entry.scrollbarHovered {
+ layer.entry.scrollbarHovered = inScrollbar
+ layer.entry.Refresh()
+ }
+}
+
+func tveIsWordRune(r rune) bool {
+ return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-'
+}
diff --git a/internal/ui/widgets/textviewentry_test.go b/internal/ui/widgets/textviewentry_test.go
new file mode 100644
index 0000000..bbb20ef
--- /dev/null
+++ b/internal/ui/widgets/textviewentry_test.go
@@ -0,0 +1,381 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTVE() *TextViewEntry {
+ e := &TextViewEntry{}
+ e.ExtendBaseWidget(e)
+ return e
+}
+
+// ── splitRaw ─────────────────────────────────────────────────────────────────
+
+func TestSplitRaw(t *testing.T) {
+ tests := []struct {
+ input string
+ want []string
+ }{
+ {"", []string{""}},
+ {"a\nb", []string{"a", "b"}},
+ {"a\r\nb", []string{"a", "b"}},
+ {"a\rb", []string{"a", "b"}},
+ {"a\nb\nc", []string{"a", "b", "c"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := splitRaw(tt.input)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// ── buildVisualLines ─────────────────────────────────────────────────────────
+
+func TestBuildVisualLines_NoWrap(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "Host: example.com"}, 80)
+ assert.Len(t, lines, 2)
+}
+
+func TestBuildVisualLines_Wraps(t *testing.T) {
+ long := strings.Repeat("x", 100)
+ lines := buildVisualLines([]string{long}, 40)
+
+ assert.Greater(t, len(lines), 2, "expected ≥3 visual lines for 100-char line at width 40")
+
+ for _, vl := range lines {
+ assert.Equal(t, 0, vl.logicalIdx, "all chunks should map to logicalIdx 0")
+ }
+
+ expected := 0
+ for _, vl := range lines {
+ assert.Equal(t, expected, vl.colOffset)
+ expected += len([]rune(vl.raw))
+ }
+}
+
+func TestBuildVisualLines_EmptyLine(t *testing.T) {
+ lines := buildVisualLines([]string{"GET /foo HTTP/1.1", "", `{"key":"val"}`}, 80)
+ assert.Len(t, lines, 3)
+}
+
+func TestBuildVisualLines_EmptyInput(t *testing.T) {
+ lines := buildVisualLines([]string{""}, 80)
+ assert.Len(t, lines, 1)
+}
+
+// ── logicalToVisual ──────────────────────────────────────────────────────────
+
+func TestLogicalToVisual_Simple(t *testing.T) {
+ visual := buildVisualLines([]string{"hello", "world"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 0)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 0, col)
+
+ v, col = logicalToVisual(visual, 1, 3)
+ assert.Equal(t, 1, visual[v].logicalIdx)
+ assert.Equal(t, 3, col)
+}
+
+func TestLogicalToVisual_EndOfLine(t *testing.T) {
+ visual := buildVisualLines([]string{"hello"}, 80)
+
+ v, col := logicalToVisual(visual, 0, 5)
+ assert.Equal(t, 0, v)
+ assert.Equal(t, 5, col)
+}
+
+func TestLogicalToVisual_WrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, col := logicalToVisual(visual, 0, 11)
+ assert.Equal(t, 10, visual[v].colOffset, "col 11 should land in chunk starting at offset 10")
+ assert.Equal(t, 1, col)
+}
+
+func TestLogicalToVisual_EndOfWrappedLine(t *testing.T) {
+ // "01234567890123456789" wrapped at 10 → chunks [0:10] [10:20]
+ visual := buildVisualLines([]string{"01234567890123456789"}, 10)
+
+ v, _ := logicalToVisual(visual, 0, 20)
+ assert.Equal(t, visual[len(visual)-1].colOffset, visual[v].colOffset, "col at end should land in last chunk")
+}
+
+// ── normaliseSelection ───────────────────────────────────────────────────────
+
+func TestNormaliseSelection_Forward(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 2
+ e.selLine, e.selCol = 1, 5
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_Reversed(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 1, 5
+ e.selLine, e.selCol = 0, 2
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 2, sc)
+ assert.Equal(t, 1, el)
+ assert.Equal(t, 5, ec)
+}
+
+func TestNormaliseSelection_SamePosition(t *testing.T) {
+ e := newTVE()
+ e.selAnchorLine, e.selAnchorCol = 0, 3
+ e.selLine, e.selCol = 0, 3
+
+ sl, sc, el, ec := e.normaliseSelection()
+ assert.Equal(t, 0, sl)
+ assert.Equal(t, 3, sc)
+ assert.Equal(t, 0, el)
+ assert.Equal(t, 3, ec)
+}
+
+// ── selectedText ─────────────────────────────────────────────────────────────
+
+func TestSelectedText_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 0
+ e.selLine, e.selCol = 0, 5
+ e.hasSelection = true
+
+ assert.Equal(t, "hello", e.selectedText())
+}
+
+func TestSelectedText_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 1, 4
+ e.hasSelection = true
+
+ assert.Equal(t, "one\nline", e.selectedText())
+}
+
+func TestSelectedText_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ assert.Equal(t, "", e.selectedText())
+}
+
+// ── deleteSelection ──────────────────────────────────────────────────────────
+
+func TestDeleteSelection_SameLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 6
+ e.selLine, e.selCol = 0, 11
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+ assert.Equal(t, 6, e.cursorCol)
+ assert.False(t, e.hasSelection)
+}
+
+func TestDeleteSelection_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "line one\nline two\nline three"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 5
+ e.selLine, e.selCol = 2, 4
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "line three", e.GetText())
+}
+
+func TestDeleteSelection_ReversedAnchor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.mu.Unlock()
+ e.selAnchorLine, e.selAnchorCol = 0, 11
+ e.selLine, e.selCol = 0, 6
+ e.hasSelection = true
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello ", e.GetText())
+}
+
+func TestDeleteSelection_NoSelection(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.hasSelection = false
+
+ lines := e.logicalLines()
+ lines = e.deleteSelection(lines)
+ e.commitLines(lines)
+
+ assert.Equal(t, "hello", e.GetText())
+}
+
+// ── pushUndo / undo ──────────────────────────────────────────────────────────
+
+func TestPushUndo_StoresCursorPosition(t *testing.T) {
+ e := newTVE()
+ e.pushUndo("hello", 0, 3)
+
+ require.Len(t, e.undoStack, 1)
+ assert.Equal(t, "hello", e.undoStack[0].raw)
+ assert.Equal(t, 0, e.undoStack[0].cursorLine)
+ assert.Equal(t, 3, e.undoStack[0].cursorCol)
+}
+
+func TestPushUndo_StackLimit(t *testing.T) {
+ e := newTVE()
+ for i := 0; i < 110; i++ {
+ e.pushUndo("x", 0, 0)
+ }
+ assert.LessOrEqual(t, len(e.undoStack), 100)
+}
+
+func TestUndo_RestoresTextAndCursor(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+ e.cursorLine, e.cursorCol = 0, 5
+
+ e.pushUndo("hello", 0, 5)
+
+ e.mu.Lock()
+ e.rawFull = "hello world"
+ e.visualCacheRaw = ""
+ e.cursorLine = 0
+ e.cursorCol = 11
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ undo := e.undoStack[len(e.undoStack)-1]
+ e.undoStack = e.undoStack[:len(e.undoStack)-1]
+ e.rawFull = undo.raw
+ e.visualCacheRaw = ""
+ e.cursorLine = undo.cursorLine
+ e.cursorCol = undo.cursorCol
+ e.mu.Unlock()
+
+ assert.Equal(t, "hello", e.GetText())
+ assert.Equal(t, 0, e.cursorLine)
+ assert.Equal(t, 5, e.cursorCol)
+}
+
+// ── visual cache ─────────────────────────────────────────────────────────────
+
+func TestVisualCache_InvalidatedOnCommit(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ e.visualCache = buildVisualLines([]string{"hello"}, 80)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = 80
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "commitLines should invalidate visual cache")
+}
+
+func TestVisualCache_HitOnSecondCall(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "GET /foo HTTP/1.1\nHost: example.com"
+ e.visualCacheRaw = ""
+ e.mu.Unlock()
+
+ // Prime the cache manually without needing a renderer.
+ cpl := 80
+ e.mu.Lock()
+ built := buildVisualLines(splitRaw(e.rawFull), cpl)
+ e.visualCache = built
+ e.visualCacheRaw = e.rawFull
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.mu.Lock()
+ c1 := e.visualCache
+ c2 := e.visualCache
+ e.mu.Unlock()
+
+ require.NotEmpty(t, c1)
+ assert.Equal(t, &c1[0], &c2[0], "expected same slice on repeated access")
+}
+
+func TestVisualCache_MissAfterMutation(t *testing.T) {
+ e := newTVE()
+ e.mu.Lock()
+ e.rawFull = "hello"
+ cpl := 80
+ e.visualCache = buildVisualLines([]string{"hello"}, cpl)
+ e.visualCacheRaw = "hello"
+ e.visualCacheWidth = cpl
+ e.mu.Unlock()
+
+ e.commitLines([]string{"world"})
+
+ e.mu.Lock()
+ cacheRaw := e.visualCacheRaw
+ e.mu.Unlock()
+
+ assert.Equal(t, "", cacheRaw, "cache should be invalidated after mutation")
+}
+
+// ── commitLines ──────────────────────────────────────────────────────────────
+
+func TestCommitLines_UpdatesText(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"new text"})
+ assert.Equal(t, "new text", e.GetText())
+}
+
+func TestCommitLines_MultiLine(t *testing.T) {
+ e := newTVE()
+ e.commitLines([]string{"line one", "line two"})
+ assert.Equal(t, "line one\nline two", e.GetText())
+}
diff --git a/internal/ui/widgets/tokeniser.go b/internal/ui/widgets/tokeniser.go
new file mode 100644
index 0000000..d471bcd
--- /dev/null
+++ b/internal/ui/widgets/tokeniser.go
@@ -0,0 +1,362 @@
+package widgets
+
+import (
+ "image/color"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/theme"
+)
+
+// tvTokenKind identifies the syntax role of a token for colour mapping.
+type tvTokenKind uint8
+
+const (
+ tvKindPlain tvTokenKind = iota
+ tvKindMethod // HTTP request method
+ tvKindPath // HTTP request path
+ tvKindVersion // HTTP version string
+ tvKindStatus2xx // 2xx response status code
+ tvKindStatus3xx // 3xx response status code
+ tvKindStatus4xx // 4xx response status code
+ tvKindStatus5xx // 5xx response status code
+ tvKindHdrName // header field name
+ tvKindHdrColon // colon separator after header name
+ tvKindHdrValue // header field value
+ tvKindJSONKey // JSON object key
+ tvKindJSONStr // JSON string value
+ tvKindJSONNum // JSON number
+ tvKindJSONBool // JSON true, false, null
+ tvKindJSONPunct // JSON structural punctuation: { } [ ] , :
+ tvKindLow // dimmed / de-emphasised text
+)
+
+// tvColor returns the theme colour for a given token kind.
+func tvColor(kind tvTokenKind) color.Color {
+ switch kind {
+ case tvKindMethod:
+ return theme.Color(theme.ColorNameError)
+ case tvKindPath:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindVersion, tvKindLow:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindStatus2xx:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindStatus3xx:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindStatus4xx, tvKindStatus5xx:
+ return theme.Color(theme.ColorNameError)
+ case tvKindHdrName:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindHdrColon, tvKindJSONPunct:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindHdrValue:
+ return theme.Color(theme.ColorNameForeground)
+ case tvKindJSONKey:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindJSONStr:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindJSONNum:
+ return theme.Color(theme.ColorNameWarning)
+ case tvKindJSONBool:
+ return theme.Color(theme.ColorNameError)
+ default:
+ return theme.Color(theme.ColorNameForeground)
+ }
+}
+
+// tvToken is a run of text sharing a single syntax colour.
+type tvToken struct {
+ Text string
+ Kind tvTokenKind
+}
+
+// tvLine is one visual line (after word-wrap) composed of tokens.
+// Raw holds the plain text for selection and clipboard operations.
+type tvLine struct {
+ Tokens []tvToken
+ Raw string
+}
+
+// parseAndWrap tokenises raw HTTP text and wraps each logical line to fit
+// within wrapWidth pixels. Returns the complete slice of visual lines.
+func parseAndWrap(s string, wrapWidth float32) []tvLine {
+ if s == "" {
+ return nil
+ }
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+
+ logicalLines := strings.Split(s, "\n")
+
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ charsPerLine := max(int(wrapWidth/charWidth), 10)
+
+ bodyContentType := detectContentType(logicalLines)
+
+ var result []tvLine
+ inBody := false
+
+ for i, rawLine := range logicalLines {
+ if !inBody && strings.TrimSpace(rawLine) == "" && i > 0 {
+ inBody = true
+ result = append(result, tvLine{Tokens: []tvToken{{Text: "", Kind: tvKindPlain}}, Raw: ""})
+ continue
+ }
+
+ var tokens []tvToken
+ if !inBody {
+ tokens = tokeniseHTTPMeta(rawLine, i == 0)
+ } else {
+ tokens = tokeniseBody(rawLine, bodyContentType)
+ }
+
+ result = append(result, wrapTokens(tokens, rawLine, charsPerLine)...)
+ }
+ return result
+}
+
+// detectContentType scans the header section to determine the body content type.
+func detectContentType(lines []string) string {
+ for _, line := range lines {
+ lower := strings.ToLower(line)
+ if strings.HasPrefix(lower, "content-type:") {
+ switch {
+ case strings.Contains(lower, "json"):
+ return "json"
+ case strings.Contains(lower, "html"):
+ return "html"
+ default:
+ return "text"
+ }
+ }
+ if line == "" {
+ break
+ }
+ }
+ return "text"
+}
+
+// tokeniseHTTPMeta tokenises one line from the HTTP header section.
+// isFirstLine indicates the request or response line.
+func tokeniseHTTPMeta(line string, isFirstLine bool) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ if isFirstLine {
+ return tokeniseFirstLine(line)
+ }
+ before, after, ok := strings.Cut(line, ":")
+ if !ok {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+ return []tvToken{
+ {Text: before, Kind: tvKindHdrName},
+ {Text: ":", Kind: tvKindHdrColon},
+ {Text: after, Kind: tvKindHdrValue},
+ }
+}
+
+// tokeniseFirstLine tokenises the HTTP request or response line.
+func tokeniseFirstLine(line string) []tvToken {
+ if strings.TrimSpace(line) == "" {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ parts := strings.Fields(line)
+ if len(parts) == 0 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ // Response line: HTTP/1.1 200 OK
+ if strings.HasPrefix(parts[0], "HTTP/") {
+ if len(parts) < 2 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ statusKind := tvKindStatus2xx
+ switch parts[1][0] {
+ case '3':
+ statusKind = tvKindStatus3xx
+ case '4':
+ statusKind = tvKindStatus4xx
+ case '5':
+ statusKind = tvKindStatus5xx
+ }
+
+ tokens := []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindVersion},
+ {Text: parts[1], Kind: statusKind},
+ }
+
+ if len(parts) > 2 {
+ tokens = append(tokens, tvToken{
+ Text: " " + strings.Join(parts[2:], " "),
+ Kind: tvKindLow,
+ })
+ }
+
+ return tokens
+ }
+
+ // Request line: GET /path HTTP/1.1
+ if len(parts) >= 3 {
+ return []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindMethod},
+ {Text: parts[1], Kind: tvKindPath},
+ {Text: " " + strings.Join(parts[2:], " "), Kind: tvKindVersion},
+ }
+ }
+
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseBody tokenises a body line according to its content type.
+func tokeniseBody(line string, contentType string) []tvToken {
+ if contentType == "json" {
+ return tokeniseJSON(line)
+ }
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseJSON performs a single-pass tokenisation of a JSON line.
+func tokeniseJSON(line string) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ var tokens []tvToken
+ runes := []rune(line)
+ pos := 0
+ for pos < len(runes) {
+ ch := runes[pos]
+ switch {
+ case ch == '"':
+ end := pos + 1
+ for end < len(runes) {
+ if runes[end] == '"' {
+ // Count preceding backslashes — even count means quote is unescaped.
+ numBackslashes := 0
+ for i := end - 1; i >= pos && runes[i] == '\\'; i-- {
+ numBackslashes++
+ }
+ if numBackslashes%2 == 0 {
+ break
+ }
+ }
+ end++
+ }
+ if end < len(runes) {
+ end++
+ }
+ str := string(runes[pos:end])
+ peek := end
+ for peek < len(runes) && runes[peek] == ' ' {
+ peek++
+ }
+ if peek < len(runes) && runes[peek] == ':' {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONKey})
+ } else {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONStr})
+ }
+ pos = end
+ case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ',' || ch == ':':
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindJSONPunct})
+ pos++
+ case ch == '-' || (ch >= '0' && ch <= '9'):
+ end := pos + 1
+ for end < len(runes) && (runes[end] >= '0' && runes[end] <= '9' || runes[end] == '.' || runes[end] == 'e' || runes[end] == 'E' || runes[end] == '+' || runes[end] == '-') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindJSONNum})
+ pos = end
+ case ch == 't' || ch == 'f' || ch == 'n':
+ matched := false
+ for _, keyword := range []string{"true", "false", "null"} {
+ if strings.HasPrefix(string(runes[pos:]), keyword) {
+ tokens = append(tokens, tvToken{Text: keyword, Kind: tvKindJSONBool})
+ pos += len([]rune(keyword))
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ case ch == ' ' || ch == '\t':
+ end := pos + 1
+ for end < len(runes) && (runes[end] == ' ' || runes[end] == '\t') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindPlain})
+ pos = end
+ default:
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ }
+ return tokens
+}
+
+// wrapTokens splits a logical line into visual lines of at most charsPerLine runes.
+func wrapTokens(tokens []tvToken, rawLine string, charsPerLine int) []tvLine {
+ if charsPerLine <= 0 {
+ charsPerLine = 80
+ }
+
+ var result []tvLine
+ var current []tvToken
+ currentLen := 0
+
+ flush := func() {
+ if len(current) == 0 {
+ return
+ }
+ var raw strings.Builder
+ for _, t := range current {
+ raw.WriteString(t.Text)
+ }
+ result = append(result, tvLine{Tokens: current, Raw: raw.String()})
+ current = nil
+ currentLen = 0
+ }
+
+ for _, token := range tokens {
+ tokenLen := len([]rune(token.Text))
+
+ // never split JSON strings
+ if token.Kind == tvKindJSONStr || token.Kind == tvKindJSONKey {
+ if currentLen+tokenLen > charsPerLine {
+ flush()
+ }
+ current = append(current, token)
+ currentLen += tokenLen
+ continue
+ }
+
+ // split other tokens if needed
+ runes := []rune(token.Text)
+ for len(runes) > 0 {
+ spaceLeft := charsPerLine - currentLen
+ if spaceLeft <= 0 {
+ flush()
+ spaceLeft = charsPerLine
+ }
+
+ n := min(len(runes), spaceLeft)
+
+ current = append(current, tvToken{
+ Text: string(runes[:n]),
+ Kind: token.Kind,
+ })
+ currentLen += n
+ runes = runes[n:]
+ }
+ }
+
+ flush()
+ return result
+}
diff --git a/internal/ui/widgets/tokeniser_test.go b/internal/ui/widgets/tokeniser_test.go
new file mode 100644
index 0000000..11c4d63
--- /dev/null
+++ b/internal/ui/widgets/tokeniser_test.go
@@ -0,0 +1,213 @@
+package widgets
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── detectContentType ────────────────────────────────────────────────────────
+
+func TestDetectContentType_JSON(t *testing.T) {
+ lines := []string{"POST /foo HTTP/1.1", "Content-Type: application/json", ""}
+ assert.Equal(t, "json", detectContentType(lines))
+}
+
+func TestDetectContentType_HTML(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Content-Type: text/html; charset=utf-8", ""}
+ assert.Equal(t, "html", detectContentType(lines))
+}
+
+func TestDetectContentType_Plain(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Content-Type: text/plain", ""}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_Missing(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "Accept: */*", ""}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_AfterBlankLineIgnored(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "", "Content-Type: application/json"}
+ assert.Equal(t, "text", detectContentType(lines))
+}
+
+func TestDetectContentType_CaseInsensitive(t *testing.T) {
+ lines := []string{"GET /foo HTTP/1.1", "content-type: APPLICATION/JSON", ""}
+ assert.Equal(t, "json", detectContentType(lines))
+}
+
+// ── tokeniseFirstLine ────────────────────────────────────────────────────────
+
+func TestTokeniseFirstLine_Request(t *testing.T) {
+ tokens := tokeniseFirstLine("GET /search?q=foo HTTP/1.1")
+
+ require.Len(t, tokens, 3)
+ assert.Equal(t, tvKindMethod, tokens[0].Kind)
+ assert.Equal(t, tvKindPath, tokens[1].Kind)
+ assert.Equal(t, tvKindVersion, tokens[2].Kind)
+}
+
+func TestTokeniseFirstLine_ResponseStatusKinds(t *testing.T) {
+ tests := []struct {
+ line string
+ wantKind tvTokenKind
+ }{
+ {"HTTP/1.1 200 OK", tvKindStatus2xx},
+ {"HTTP/1.1 201 Created", tvKindStatus2xx},
+ {"HTTP/1.1 301 Moved Permanently", tvKindStatus3xx},
+ {"HTTP/1.1 404 Not Found", tvKindStatus4xx},
+ {"HTTP/1.1 500 Internal Server Error", tvKindStatus5xx},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.line, func(t *testing.T) {
+ tokens := tokeniseFirstLine(tt.line)
+ require.GreaterOrEqual(t, len(tokens), 2)
+ assert.Equal(t, tt.wantKind, tokens[1].Kind)
+ })
+ }
+}
+
+// ── tokeniseHTTPMeta ─────────────────────────────────────────────────────────
+
+func TestTokeniseHTTPMeta_Header(t *testing.T) {
+ tokens := tokeniseHTTPMeta("Content-Type: application/json", false)
+
+ require.Len(t, tokens, 3)
+ assert.Equal(t, tvKindHdrName, tokens[0].Kind)
+ assert.Equal(t, "Content-Type", tokens[0].Text)
+ assert.Equal(t, tvKindHdrColon, tokens[1].Kind)
+ assert.Equal(t, tvKindHdrValue, tokens[2].Kind)
+}
+
+func TestTokeniseHTTPMeta_EmptyLine(t *testing.T) {
+ tokens := tokeniseHTTPMeta("", false)
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+ assert.Equal(t, "", tokens[0].Text)
+}
+
+func TestTokeniseHTTPMeta_NoColon(t *testing.T) {
+ tokens := tokeniseHTTPMeta("plaintext", false)
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+}
+
+// ── tokeniseJSON ─────────────────────────────────────────────────────────────
+
+func TestTokeniseJSON_KeyAndString(t *testing.T) {
+ tokens := tokeniseJSON(`"name": "alice"`)
+
+ kinds := map[tvTokenKind]bool{}
+ for _, tok := range tokens {
+ kinds[tok.Kind] = true
+ }
+ assert.True(t, kinds[tvKindJSONKey], "expected tvKindJSONKey token")
+ assert.True(t, kinds[tvKindJSONStr], "expected tvKindJSONStr token")
+}
+
+func TestTokeniseJSON_Number(t *testing.T) {
+ tokens := tokeniseJSON(`"count": 42`)
+
+ found := false
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONNum && tok.Text == "42" {
+ found = true
+ }
+ }
+ assert.True(t, found, "expected number token '42'")
+}
+
+func TestTokeniseJSON_BoolAndNull(t *testing.T) {
+ for _, kw := range []string{"true", "false", "null"} {
+ t.Run(kw, func(t *testing.T) {
+ tokens := tokeniseJSON(`"flag": ` + kw)
+ found := false
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONBool && tok.Text == kw {
+ found = true
+ }
+ }
+ assert.True(t, found, "expected tvKindJSONBool token %q", kw)
+ })
+ }
+}
+
+func TestTokeniseJSON_Punctuation(t *testing.T) {
+ for _, ch := range []string{"{", "}", "[", "]", ","} {
+ t.Run(ch, func(t *testing.T) {
+ tokens := tokeniseJSON(ch)
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindJSONPunct, tokens[0].Kind)
+ })
+ }
+}
+
+func TestTokeniseJSON_EscapedQuoteInsideString(t *testing.T) {
+ tokens := tokeniseJSON(`"say \"hi\""`)
+
+ strCount := 0
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONStr || tok.Kind == tvKindJSONKey {
+ strCount++
+ }
+ }
+ assert.Equal(t, 1, strCount, "escaped quote should produce exactly 1 string token")
+}
+
+func TestTokeniseJSON_DoubleBackslashBeforeQuote(t *testing.T) {
+ tokens := tokeniseJSON(`"\\"`)
+
+ strCount := 0
+ for _, tok := range tokens {
+ if tok.Kind == tvKindJSONStr || tok.Kind == tvKindJSONKey {
+ strCount++
+ }
+ }
+ assert.Equal(t, 1, strCount, "double backslash before closing quote should produce exactly 1 string token")
+}
+
+func TestTokeniseJSON_Empty(t *testing.T) {
+ tokens := tokeniseJSON("")
+
+ require.Len(t, tokens, 1)
+ assert.Equal(t, tvKindPlain, tokens[0].Kind)
+}
+
+// ── wrapTokens ───────────────────────────────────────────────────────────────
+
+func TestWrapTokens_ShortLineNoWrap(t *testing.T) {
+ tokens := []tvToken{{Text: "hello", Kind: tvKindPlain}}
+ lines := wrapTokens(tokens, "hello", 80)
+
+ require.Len(t, lines, 1)
+ assert.Equal(t, "hello", lines[0].Raw)
+}
+
+func TestWrapTokens_LongLineWraps(t *testing.T) {
+ long := strings.Repeat("x", 100)
+ tokens := []tvToken{{Text: long, Kind: tvKindPlain}}
+ lines := wrapTokens(tokens, long, 40)
+
+ assert.Greater(t, len(lines), 1, "expected wrapping into multiple lines")
+
+ total := 0
+ for _, l := range lines {
+ total += len([]rune(l.Raw))
+ }
+ assert.Equal(t, 100, total, "all characters must be preserved across wrapped lines")
+}
+
+func TestWrapTokens_JSONStringNotSplit(t *testing.T) {
+ str := `"` + strings.Repeat("a", 50) + `"`
+ tokens := []tvToken{{Text: str, Kind: tvKindJSONStr}}
+ lines := wrapTokens(tokens, str, 20)
+
+ assert.Len(t, lines, 1, "JSON string must not be split across lines")
+}