From 7879720d0710a9a2f988f599cc4873fd36ca1380 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue, 12 May 2026 22:01:29 +0200 Subject: [PATCH] Remove scope page Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .github/docs/scopes.md | 19 ---- README.md | 1 - internal/config/config.go | 5 +- internal/config/default_config.yaml | 2 + internal/db/db.go | 10 +- internal/db/scope.go | 45 --------- internal/intercept/broker.go | 78 +++++---------- internal/plugins/lua.go | 22 +--- internal/ui/app/model.go | 7 -- internal/ui/app/pages.go | 15 --- internal/ui/app/update.go | 10 -- internal/ui/docs/model.go | 1 - internal/ui/scope/model.go | 150 ---------------------------- internal/ui/scope/update.go | 70 ------------- internal/ui/scope/view.go | 84 ---------------- 15 files changed, 30 insertions(+), 489 deletions(-) delete mode 100644 .github/docs/scopes.md delete mode 100644 internal/db/scope.go delete mode 100644 internal/ui/scope/model.go delete mode 100644 internal/ui/scope/update.go delete mode 100644 internal/ui/scope/view.go diff --git a/.github/docs/scopes.md b/.github/docs/scopes.md deleted file mode 100644 index 3bb1515..0000000 --- a/.github/docs/scopes.md +++ /dev/null @@ -1,19 +0,0 @@ -## Scopes - -Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`). - -- **Whitelist**: if non-empty, only matching requests are intercepted. -- **Blacklist**: matching requests are always ignored, even if whitelisted. - -When both lists are set, a request must pass the whitelist _and_ not be in the blacklist. - -### Examples - -| Pattern | Matches | -| -------------------------- | ----------------------------------- | -| `example\.com` | any request to `example.com` | -| `^api\.example\.com` | only the `api` subdomain | -| `example\.com/api/v2` | a specific path prefix | -| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) | -| `googleapis\.com` | all Google API traffic | -| `/graphql$` | any host with a `/graphql` endpoint | diff --git a/README.md b/README.md index 8c5cf3b..9331bbb 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key - **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding. - **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history. - **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration -- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths. - **HTTPS Support** (using go-mitmproxy under the hood) - Built-in Integrations: - **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly. diff --git a/internal/config/config.go b/internal/config/config.go index 1d79666..2e05e6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,8 +33,9 @@ type Config struct { } `mapstructure:"tui"` Intercept struct { - DefaultAutoForward bool `mapstructure:"default_auto_forward"` - DefaultCaptureResponse bool `mapstructure:"default_capture_response"` + DefaultAutoForward bool `mapstructure:"default_auto_forward"` + DefaultCaptureResponse bool `mapstructure:"default_capture_response"` + AutoForwardRegex []string `mapstructure:"auto_forward_regex"` } `mapstructure:"intercept"` Replay struct { diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index da8b76c..60a006e 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -8,6 +8,8 @@ app: intercept: default_auto_forward: false default_capture_response: false + auto_forward_regex: + - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' replay: switch_to_page_on_send: false diff --git a/internal/db/db.go b/internal/db/db.go index 20b4704..f7ff255 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -35,12 +35,7 @@ func (d *DB) migrate() error { request_raw TEXT NOT NULL, response_raw TEXT NOT NULL ); - CREATE TABLE IF NOT EXISTS scope ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - kind TEXT NOT NULL CHECK(kind IN ('whitelist','blacklist')), - pattern TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS replay_entries ( +CREATE TABLE IF NOT EXISTS replay_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME NOT NULL, scheme TEXT NOT NULL, @@ -69,9 +64,6 @@ func (d *DB) migrate() error { created_at DATETIME NOT NULL, UNIQUE(plugin_name, dedup_key) ); - INSERT INTO scope (kind, pattern) - SELECT 'blacklist', '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' - WHERE NOT EXISTS (SELECT 1 FROM scope); `) return err } diff --git a/internal/db/scope.go b/internal/db/scope.go deleted file mode 100644 index 9b3d731..0000000 --- a/internal/db/scope.go +++ /dev/null @@ -1,45 +0,0 @@ -package db - -func (d *DB) SaveScope(whitelist, blacklist []string) error { - tx, err := d.conn.Begin() - if err != nil { - return err - } - if _, err := tx.Exec(`DELETE FROM scope`); err != nil { - tx.Rollback() - return err - } - for _, p := range whitelist { - if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil { - tx.Rollback() - return err - } - } - for _, p := range blacklist { - if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil { - tx.Rollback() - return err - } - } - return tx.Commit() -} - -func (d *DB) LoadScope() (whitelist, blacklist []string, err error) { - rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`) - if err != nil { - return nil, nil, err - } - defer rows.Close() - for rows.Next() { - var kind, pattern string - if err := rows.Scan(&kind, &pattern); err != nil { - return nil, nil, err - } - if kind == "whitelist" { - whitelist = append(whitelist, pattern) - } else { - blacklist = append(blacklist, pattern) - } - } - return whitelist, blacklist, rows.Err() -} diff --git a/internal/intercept/broker.go b/internal/intercept/broker.go index 58ac045..2fd2717 100644 --- a/internal/intercept/broker.go +++ b/internal/intercept/broker.go @@ -41,9 +41,8 @@ type Broker struct { droppedFlows sync.Map // *proxy.Flow → struct{} outOfScope sync.Map // *proxy.Flow → struct{} - scopeMu sync.RWMutex - whitelist []*regexp.Regexp - blacklist []*regexp.Regexp + autoFwdMu sync.RWMutex + autoFwdRegexes []*regexp.Regexp onNewEntry func(db.Entry) } @@ -52,76 +51,44 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { b.onNewEntry = cb } -// IsInScope reports whether the given target string (host+path) matches the -// current scope rules. Used by the plugin API. -func (b *Broker) IsInScope(target string) bool { - b.scopeMu.RLock() - wl := b.whitelist - bl := b.blacklist - b.scopeMu.RUnlock() - return scopeMatches(wl, bl, target) -} - func NewBroker() *Broker { - return &Broker{ + b := &Broker{ Incoming: make(chan *PendingRequest, 64), IncomingResponse: make(chan *PendingResponse, 64), } + b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex) + return b } func (b *Broker) SetCaptureResponse(v bool) { b.captureResponse.Store(v) } -// SetScope compiles and stores whitelist/blacklist regex patterns. +// SetAutoForwardRegex compiles and stores patterns for requests that should +// be forwarded automatically without interception or history logging. // Invalid patterns are silently skipped. -func (b *Broker) SetScope(whitelist, blacklist []string) { - wl := compilePatterns(whitelist) - bl := compilePatterns(blacklist) - b.scopeMu.Lock() - b.whitelist = wl - b.blacklist = bl - b.scopeMu.Unlock() -} - -func compilePatterns(patterns []string) []*regexp.Regexp { - out := make([]*regexp.Regexp, 0, len(patterns)) +func (b *Broker) SetAutoForwardRegex(patterns []string) { + compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { if r, err := regexp.Compile(p); err == nil { - out = append(out, r) + compiled = append(compiled, r) } } - return out + b.autoFwdMu.Lock() + b.autoFwdRegexes = compiled + b.autoFwdMu.Unlock() } -func (b *Broker) matchesScope(f *proxy.Flow) bool { - target := f.Request.URL.Host + f.Request.URL.Path - b.scopeMu.RLock() - wl := b.whitelist - bl := b.blacklist - b.scopeMu.RUnlock() - return scopeMatches(wl, bl, target) -} - -func scopeMatches(wl, bl []*regexp.Regexp, target string) bool { - if len(wl) > 0 { - matched := false - for _, r := range wl { - if r.MatchString(target) { - matched = true - break - } - } - if !matched { - return false - } - } - for _, r := range bl { +func (b *Broker) isAutoForwarded(target string) bool { + b.autoFwdMu.RLock() + regexes := b.autoFwdRegexes + b.autoFwdMu.RUnlock() + for _, r := range regexes { if r.MatchString(target) { - return false + return true } } - return true + return false } func (b *Broker) SetDB(d *db.DB) { @@ -132,7 +99,8 @@ func (b *Broker) SetDB(d *db.DB) { // Hold is called from the proxy addon: it blocks until a decision is made in the TUI. func (b *Broker) Hold(f *proxy.Flow) Decision { - if !b.matchesScope(f) { + target := f.Request.URL.Host + f.Request.URL.Path + if b.isAutoForwarded(target) { b.outOfScope.Store(f, struct{}{}) return Forward } @@ -168,7 +136,7 @@ func (b *Broker) HoldResponse(f *proxy.Flow) Decision { // SaveEntry persists the completed flow to the history DB. // It must be called after HoldResponse and before modifying f.Response. -// Flows that were dropped at the request phase are silently skipped. +// Flows that were dropped or auto-forwarded are silently skipped. func (b *Broker) SaveEntry(f *proxy.Flow) { b.dbMu.RLock() d := b.database diff --git a/internal/plugins/lua.go b/internal/plugins/lua.go index e838a56..2b33e66 100644 --- a/internal/plugins/lua.go +++ b/internal/plugins/lua.go @@ -2,7 +2,6 @@ package plugins import ( "log" - "net/url" "strings" "time" @@ -65,26 +64,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) { return 0 })) - L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int { - raw := L.CheckString(1) - if mgr.broker == nil { - L.Push(lua.LTrue) - return 1 - } - u, err := url.Parse(raw) - if err != nil { - L.Push(lua.LFalse) - return 1 - } - path := u.Path - if path == "" { - path = "/" - } - L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path))) - return 1 - })) - - L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { +L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { reason := L.OptString(1, "plugin requested quit") select { case mgr.Quit <- reason: diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 1536bfa..4c4bcf2 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -22,7 +22,6 @@ import ( interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" - scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" "github.com/sirupsen/logrus" ) @@ -64,7 +63,6 @@ type Model struct { replay replayUI.Model diff diffUI.Model docs docsUI.Model - scope scopeUI.Model pluginsPage pluginsUI.Model findingsPage findingsUI.Model copyAs copyasUI.Model @@ -86,7 +84,6 @@ func New(broker *intercept.Broker, name, path string) Model { replay: replayUI.New(), diff: diffUI.New(), docs: docsUI.New(), - scope: scopeUI.New(name, path), pluginsPage: pluginsUI.New(mgr), findingsPage: findingsUI.New(), copyAs: copyasUI.New(), @@ -101,10 +98,6 @@ func New(broker *intercept.Broker, name, path string) Model { m.replay.SetDB(d) m.findingsPage.SetDB(d) mgr.SetDB(d) - if wl, bl, err := d.LoadScope(); err == nil { - broker.SetScope(wl, bl) - m.scope.SetScope(wl, bl) - } } pluginsDir := config.ExpandPath(cfg.App.PluginsDir) diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go index 93baa3d..b1b2e36 100644 --- a/internal/ui/app/pages.go +++ b/internal/ui/app/pages.go @@ -10,7 +10,6 @@ import ( interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" - scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" ) type page string @@ -20,7 +19,6 @@ const ( pageHistory page = "History" pageReplay page = "Replay" pageDiff page = "Diff" - pageScopes page = "Scopes" pagePlugins page = "Plugins" pageFindings page = "Findings" pageDocs page = "Docs" @@ -93,19 +91,6 @@ var pageRegistry = []pageEntry{ }, resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) }, }, - { - id: pageScopes, - icon: func() string { return icons.I.Scope }, - - render: func(m *Model) string { return m.scope.View().Content }, - update: func(m *Model, msg tea.Msg) tea.Cmd { - updated, cmd := m.scope.Update(msg) - m.scope = updated.(scopeUI.Model) - return cmd - }, - isEditing: func(m *Model) bool { return m.scope.IsEditing() }, - resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) }, - }, { id: pagePlugins, icon: func() string { return icons.I.Plugin }, diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index ca60aee..42cf7af 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -20,7 +20,6 @@ import ( historyUI "github.com/anotherhadi/spilltea/internal/ui/history" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" - scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -79,15 +78,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.resizeChildren() - case scopeUI.ScopeChangedMsg: - m.broker.SetScope(msg.Whitelist, msg.Blacklist) - if m.database != nil { - if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil { - log.Printf("failed to persist scope: %v", err) - } - } - return m, nil - case proxyPkg.ErrMsg: if msg.Err != nil { log.Printf("proxy error: %v", msg.Err) diff --git a/internal/ui/docs/model.go b/internal/ui/docs/model.go index fad410d..ea4dca4 100644 --- a/internal/ui/docs/model.go +++ b/internal/ui/docs/model.go @@ -19,7 +19,6 @@ var contentMarkdown = strings.Join([]string{ readDoc("proxy.md"), readDoc("certificate.md"), readDoc("history.md"), - readDoc("scopes.md"), }, "\n") type Model struct { diff --git a/internal/ui/scope/model.go b/internal/ui/scope/model.go deleted file mode 100644 index 1e09a4c..0000000 --- a/internal/ui/scope/model.go +++ /dev/null @@ -1,150 +0,0 @@ -package scope - -import ( - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/anotherhadi/spilltea/internal/keys" - "github.com/anotherhadi/spilltea/internal/style" -) - -const ( - fieldNone = -1 - fieldWhitelist = 0 - fieldBlacklist = 1 -) - -const ( - minTaH = 3 - maxTaH = 12 - fixedH = 8 // (blank + label + desc + blank) x2 -) - -type ScopeChangedMsg struct { - Whitelist []string - Blacklist []string -} - -type Model struct { - focusIdx int - - wlTextarea textarea.Model - blTextarea textarea.Model - - innerH int - width int - height int - - help help.Model -} - -func New(name, path string) Model { - wl := style.NewTextarea(true) - wl.Placeholder = "one pattern per line..." - - bl := style.NewTextarea(true) - bl.Placeholder = "one pattern per line..." - bl.Blur() - - return Model{ - focusIdx: fieldNone, - wlTextarea: wl, - blTextarea: bl, - help: style.NewHelp(), - } -} - -func (m Model) Init() tea.Cmd { return nil } - -func (m *Model) SetScope(whitelist, blacklist []string) { - m.wlTextarea.SetValue(strings.Join(whitelist, "\n")) - m.blTextarea.SetValue(strings.Join(blacklist, "\n")) -} - -func (m *Model) SetSize(w, h int) { - m.width = w - m.height = h - m.syncLayout() -} - -func (m *Model) syncLayout() { - if m.width == 0 { - return - } - m.help.SetWidth(m.width - 2) - - statusH := strings.Count(m.renderStatusBar(), "\n") + 1 - panelH := m.height - statusH - m.innerH = max(1, style.PanelContentH(panelH)) - - taH := (m.innerH - fixedH) / 2 - if taH < minTaH { - taH = minTaH - } - if taH > maxTaH { - taH = maxTaH - } - // width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor) - taW := max(1, m.width-6) - m.wlTextarea.SetWidth(taW) - m.wlTextarea.SetHeight(taH) - m.blTextarea.SetWidth(taW) - m.blTextarea.SetHeight(taH) -} - -func (m Model) IsEditing() bool { - return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist -} - -func (m *Model) scopeChangedCmd() tea.Cmd { - wl := parseLines(m.wlTextarea.Value()) - bl := parseLines(m.blTextarea.Value()) - return func() tea.Msg { - return ScopeChangedMsg{Whitelist: wl, Blacklist: bl} - } -} - -func parseLines(s string) []string { - var out []string - for _, line := range strings.Split(s, "\n") { - if t := strings.TrimSpace(line); t != "" { - out = append(out, t) - } - } - return out -} - -func (m Model) renderStatusBar() string { - return lipgloss.NewStyle().Padding(0, 1).Render( - m.help.View(formKeyMap{focusIdx: m.focusIdx}), - ) -} - -type formKeyMap struct { - focusIdx int -} - -func (k formKeyMap) ShortHelp() []key.Binding { - cycle := keys.Keys.Global.CycleFocus - hlp := keys.Keys.Global.Help - - switch k.focusIdx { - case fieldWhitelist, fieldBlacklist: - esc := keys.Keys.Global.Escape - escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus")) - return []key.Binding{ - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")), - escBinding, - cycle, - } - } - return []key.Binding{cycle, hlp} -} - -func (k formKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} diff --git a/internal/ui/scope/update.go b/internal/ui/scope/update.go deleted file mode 100644 index 9ba98ea..0000000 --- a/internal/ui/scope/update.go +++ /dev/null @@ -1,70 +0,0 @@ -package scope - -import ( - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "github.com/anotherhadi/spilltea/internal/keys" -) - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - kp, isKey := msg.(tea.KeyPressMsg) - if !isKey { - return m, nil - } - - if key.Matches(kp, keys.Keys.Global.CycleFocus) { - return m.cycleFocus() - } - - if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() { - m.help.ShowAll = !m.help.ShowAll - return m, nil - } - - switch m.focusIdx { - case fieldWhitelist: - if key.Matches(kp, keys.Keys.Global.Escape) { - return m.blurAll() - } - var cmd tea.Cmd - m.wlTextarea, cmd = m.wlTextarea.Update(kp) - return m, cmd - - case fieldBlacklist: - if key.Matches(kp, keys.Keys.Global.Escape) { - return m.blurAll() - } - var cmd tea.Cmd - m.blTextarea, cmd = m.blTextarea.Update(kp) - return m, cmd - } - - return m, nil -} - -func (m Model) blurAll() (tea.Model, tea.Cmd) { - m.wlTextarea.Blur() - m.blTextarea.Blur() - m.focusIdx = fieldNone - m.syncLayout() - return m, m.scopeChangedCmd() -} - -func (m Model) cycleFocus() (tea.Model, tea.Cmd) { - scopeCmd := m.scopeChangedCmd() - - var focusCmd tea.Cmd - switch m.focusIdx { - case fieldNone, fieldBlacklist: - m.blTextarea.Blur() - m.focusIdx = fieldWhitelist - focusCmd = m.wlTextarea.Focus() - case fieldWhitelist: - m.wlTextarea.Blur() - m.focusIdx = fieldBlacklist - focusCmd = m.blTextarea.Focus() - } - - m.syncLayout() - return m, tea.Batch(focusCmd, scopeCmd) -} diff --git a/internal/ui/scope/view.go b/internal/ui/scope/view.go deleted file mode 100644 index 0d25194..0000000 --- a/internal/ui/scope/view.go +++ /dev/null @@ -1,84 +0,0 @@ -package scope - -import ( - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - "github.com/anotherhadi/spilltea/internal/icons" - "github.com/anotherhadi/spilltea/internal/style" -) - -func (m Model) View() tea.View { - if m.width == 0 { - return tea.NewView("") - } - - s := style.S - - statusBar := m.renderStatusBar() - statusH := strings.Count(statusBar, "\n") + 1 - panelH := m.height - statusH - innerH := max(1, style.PanelContentH(panelH)) - - taH := (innerH - fixedH) / 2 - if taH < minTaH { - taH = minTaH - } - if taH > maxTaH { - taH = maxTaH - } - - var lines []string - add := func(l string) { lines = append(lines, l) } - - add("") - add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist)) - add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted.")) - add("") - wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1 - for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) { - add(" " + l) - } - - add("") - add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist)) - add(" " + s.Faint.Render("Matching requests are always excluded from history.")) - add("") - blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1 - for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) { - add(" " + l) - } - - for len(lines) < innerH { - lines = append(lines, "") - } - content := strings.Join(lines[:innerH], "\n") - - panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH) - return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar)) -} - -func fieldLabel(name string, focused bool) string { - s := style.S - c := s.MutedFg - if focused { - c = s.Primary - } - return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name) -} - -func taLines(view string, h int, contentLines int) []string { - raw := strings.Split(strings.TrimRight(view, "\n"), "\n") - tilde := style.S.Faint.Render("~") - for len(raw) < h { - raw = append(raw, tilde) - } - if len(raw) > h { - raw = raw[:h] - } - for i := contentLines; i < len(raw); i++ { - raw[i] = tilde - } - return raw -}