From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/ui/app.go | 239 +++++ internal/ui/browser.go | 283 +++++ internal/ui/decoder.go | 254 +++++ internal/ui/format.go | 120 +++ internal/ui/har.go | 88 ++ internal/ui/helpers.go | 91 ++ internal/ui/history.go | 1303 +++++++++++++++++++++++ internal/ui/history_test.go | 124 +++ internal/ui/inspector.go | 125 +++ internal/ui/intercept.go | 216 ++++ internal/ui/intruder.go | 742 ++++++++++++++ internal/ui/keybinds.go | 92 ++ internal/ui/launch.go | 219 ++++ internal/ui/loot.go | 394 +++++++ internal/ui/plugins.go | 179 ++++ internal/ui/repeater.go | 224 ++++ internal/ui/repeater_http.go | 310 ++++++ internal/ui/repeater_test.go | 378 +++++++ internal/ui/repeater_ws.go | 418 ++++++++ internal/ui/scope.go | 126 +++ internal/ui/settings.go | 401 ++++++++ internal/ui/theme.go | 373 +++++++ internal/ui/websocket.go | 379 +++++++ internal/ui/widgets/datatable.go | 876 ++++++++++++++++ internal/ui/widgets/datatable_test.go | 199 ++++ internal/ui/widgets/textview.go | 836 +++++++++++++++ internal/ui/widgets/textview_test.go | 189 ++++ internal/ui/widgets/textviewentry.go | 1589 +++++++++++++++++++++++++++++ internal/ui/widgets/textviewentry_test.go | 381 +++++++ internal/ui/widgets/tokeniser.go | 362 +++++++ internal/ui/widgets/tokeniser_test.go | 213 ++++ 31 files changed, 11723 insertions(+) create mode 100644 internal/ui/app.go create mode 100644 internal/ui/browser.go create mode 100644 internal/ui/decoder.go create mode 100644 internal/ui/format.go create mode 100644 internal/ui/har.go create mode 100644 internal/ui/helpers.go create mode 100644 internal/ui/history.go create mode 100644 internal/ui/history_test.go create mode 100644 internal/ui/inspector.go create mode 100644 internal/ui/intercept.go create mode 100644 internal/ui/intruder.go create mode 100644 internal/ui/keybinds.go create mode 100644 internal/ui/launch.go create mode 100644 internal/ui/loot.go create mode 100644 internal/ui/plugins.go create mode 100644 internal/ui/repeater.go create mode 100644 internal/ui/repeater_http.go create mode 100644 internal/ui/repeater_test.go create mode 100644 internal/ui/repeater_ws.go create mode 100644 internal/ui/scope.go create mode 100644 internal/ui/settings.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/websocket.go create mode 100644 internal/ui/widgets/datatable.go create mode 100644 internal/ui/widgets/datatable_test.go create mode 100644 internal/ui/widgets/textview.go create mode 100644 internal/ui/widgets/textview_test.go create mode 100644 internal/ui/widgets/textviewentry.go create mode 100644 internal/ui/widgets/textviewentry_test.go create mode 100644 internal/ui/widgets/tokeniser.go create mode 100644 internal/ui/widgets/tokeniser_test.go (limited to 'internal/ui') 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(" \n", name, val)) + } + } + + var b strings.Builder + b.WriteString("\n\n\n") + b.WriteString(fmt.Sprintf("
\n", htmlAttrEscape(action), method)) + b.WriteString(fields.String()) + b.WriteString(" \n") + b.WriteString("
\n") + b.WriteString("\n") + b.WriteString("\n\n") + return b.String() +} + +func htmlAttrEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + 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\nExample:\nGET /search?q=$ 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 $.", 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") +} -- cgit v1.2.3