feat(tui): add process features watch and kill
This commit is contained in:
@@ -73,6 +73,9 @@ g/G top/bottom
|
|||||||
t/u toggle tcp/udp
|
t/u toggle tcp/udp
|
||||||
l/e/o toggle listen/established/other
|
l/e/o toggle listen/established/other
|
||||||
s/S cycle sort / reverse
|
s/S cycle sort / reverse
|
||||||
|
w watch/monitor process (highlight)
|
||||||
|
W clear all watched
|
||||||
|
K kill process (with confirmation)
|
||||||
/ search
|
/ search
|
||||||
enter connection details
|
enter connection details
|
||||||
? help
|
? help
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"snitch/internal/collector"
|
"snitch/internal/collector"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -12,6 +14,11 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleSearchKey(msg)
|
return m.handleSearchKey(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kill confirmation dialog
|
||||||
|
if m.showKillConfirm {
|
||||||
|
return m.handleKillConfirmKey(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// detail view only allows closing
|
// detail view only allows closing
|
||||||
if m.showDetail {
|
if m.showDetail {
|
||||||
return m.handleDetailKey(msg)
|
return m.handleDetailKey(msg)
|
||||||
@@ -62,6 +69,25 @@ func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) handleKillConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
if m.killTarget != nil && m.killTarget.PID > 0 {
|
||||||
|
pid := m.killTarget.PID
|
||||||
|
process := m.killTarget.Process
|
||||||
|
m.showKillConfirm = false
|
||||||
|
m.killTarget = nil
|
||||||
|
return m, killProcess(pid, process)
|
||||||
|
}
|
||||||
|
m.showKillConfirm = false
|
||||||
|
m.killTarget = nil
|
||||||
|
case "n", "N", "esc", "q":
|
||||||
|
m.showKillConfirm = false
|
||||||
|
m.killTarget = nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
@@ -135,6 +161,55 @@ func (m model) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.fetchData()
|
return m, m.fetchData()
|
||||||
case "?":
|
case "?":
|
||||||
m.showHelp = true
|
m.showHelp = true
|
||||||
|
|
||||||
|
// watch/monitor process
|
||||||
|
case "w":
|
||||||
|
visible := m.visibleConnections()
|
||||||
|
if m.cursor < len(visible) {
|
||||||
|
conn := visible[m.cursor]
|
||||||
|
if conn.PID > 0 {
|
||||||
|
wasWatched := m.isWatched(conn.PID)
|
||||||
|
m.toggleWatch(conn.PID)
|
||||||
|
|
||||||
|
// count connections for this pid
|
||||||
|
connCount := 0
|
||||||
|
for _, c := range m.connections {
|
||||||
|
if c.PID == conn.PID {
|
||||||
|
connCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wasWatched {
|
||||||
|
m.statusMessage = fmt.Sprintf("unwatched %s (pid %d)", conn.Process, conn.PID)
|
||||||
|
} else if connCount > 1 {
|
||||||
|
m.statusMessage = fmt.Sprintf("watching %s (pid %d) - %d connections", conn.Process, conn.PID, connCount)
|
||||||
|
} else {
|
||||||
|
m.statusMessage = fmt.Sprintf("watching %s (pid %d)", conn.Process, conn.PID)
|
||||||
|
}
|
||||||
|
m.statusExpiry = time.Now().Add(2 * time.Second)
|
||||||
|
return m, clearStatusAfter(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "W":
|
||||||
|
// clear all watched
|
||||||
|
count := len(m.watchedPIDs)
|
||||||
|
m.watchedPIDs = make(map[int]bool)
|
||||||
|
if count > 0 {
|
||||||
|
m.statusMessage = fmt.Sprintf("cleared %d watched processes", count)
|
||||||
|
m.statusExpiry = time.Now().Add(2 * time.Second)
|
||||||
|
return m, clearStatusAfter(2 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kill process
|
||||||
|
case "K":
|
||||||
|
visible := m.visibleConnections()
|
||||||
|
if m.cursor < len(visible) {
|
||||||
|
conn := visible[m.cursor]
|
||||||
|
if conn.PID > 0 {
|
||||||
|
m.killTarget = &conn
|
||||||
|
m.showKillConfirm = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"snitch/internal/collector"
|
"snitch/internal/collector"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -17,6 +19,15 @@ type errMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type killResultMsg struct {
|
||||||
|
pid int
|
||||||
|
process string
|
||||||
|
success bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type clearStatusMsg struct{}
|
||||||
|
|
||||||
func (m model) tick() tea.Cmd {
|
func (m model) tick() tea.Cmd {
|
||||||
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
return tea.Tick(m.interval, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
@@ -33,3 +44,40 @@ func (m model) fetchData() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func killProcess(pid int, process string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if pid <= 0 {
|
||||||
|
return killResultMsg{
|
||||||
|
pid: pid,
|
||||||
|
process: process,
|
||||||
|
success: false,
|
||||||
|
err: fmt.Errorf("invalid pid"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send SIGTERM first (graceful shutdown)
|
||||||
|
err := syscall.Kill(pid, syscall.SIGTERM)
|
||||||
|
if err != nil {
|
||||||
|
return killResultMsg{
|
||||||
|
pid: pid,
|
||||||
|
process: process,
|
||||||
|
success: false,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return killResultMsg{
|
||||||
|
pid: pid,
|
||||||
|
process: process,
|
||||||
|
success: true,
|
||||||
|
err: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearStatusAfter(d time.Duration) tea.Cmd {
|
||||||
|
return tea.Tick(d, func(t time.Time) tea.Msg {
|
||||||
|
return clearStatusMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"snitch/internal/collector"
|
"snitch/internal/collector"
|
||||||
"snitch/internal/theme"
|
"snitch/internal/theme"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,6 +36,17 @@ type model struct {
|
|||||||
interval time.Duration
|
interval time.Duration
|
||||||
lastRefresh time.Time
|
lastRefresh time.Time
|
||||||
err error
|
err error
|
||||||
|
|
||||||
|
// watched processes
|
||||||
|
watchedPIDs map[int]bool
|
||||||
|
|
||||||
|
// kill confirmation
|
||||||
|
showKillConfirm bool
|
||||||
|
killTarget *collector.Connection
|
||||||
|
|
||||||
|
// status message (temporary feedback)
|
||||||
|
statusMessage string
|
||||||
|
statusExpiry time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -93,6 +105,7 @@ func New(opts Options) model {
|
|||||||
theme: theme.GetTheme(opts.Theme),
|
theme: theme.GetTheme(opts.Theme),
|
||||||
interval: interval,
|
interval: interval,
|
||||||
lastRefresh: time.Now(),
|
lastRefresh: time.Now(),
|
||||||
|
watchedPIDs: make(map[int]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +140,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case errMsg:
|
case errMsg:
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case killResultMsg:
|
||||||
|
if msg.success {
|
||||||
|
m.statusMessage = fmt.Sprintf("killed %s (pid %d)", msg.process, msg.pid)
|
||||||
|
} else {
|
||||||
|
m.statusMessage = fmt.Sprintf("failed to kill pid %d: %v", msg.pid, msg.err)
|
||||||
|
}
|
||||||
|
m.statusExpiry = time.Now().Add(3 * time.Second)
|
||||||
|
return m, tea.Batch(m.fetchData(), clearStatusAfter(3*time.Second))
|
||||||
|
|
||||||
|
case clearStatusMsg:
|
||||||
|
if time.Now().After(m.statusExpiry) {
|
||||||
|
m.statusMessage = ""
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -142,7 +170,15 @@ func (m model) View() string {
|
|||||||
if m.showDetail && m.selected != nil {
|
if m.showDetail && m.selected != nil {
|
||||||
return m.renderDetail()
|
return m.renderDetail()
|
||||||
}
|
}
|
||||||
return m.renderMain()
|
|
||||||
|
main := m.renderMain()
|
||||||
|
|
||||||
|
// overlay kill confirmation modal on top of main view
|
||||||
|
if m.showKillConfirm && m.killTarget != nil {
|
||||||
|
return m.overlayModal(main, m.renderKillModal())
|
||||||
|
}
|
||||||
|
|
||||||
|
return main
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) applySorting() {
|
func (m *model) applySorting() {
|
||||||
@@ -167,7 +203,8 @@ func (m *model) clampCursor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) visibleConnections() []collector.Connection {
|
func (m model) visibleConnections() []collector.Connection {
|
||||||
var result []collector.Connection
|
var watched []collector.Connection
|
||||||
|
var unwatched []collector.Connection
|
||||||
|
|
||||||
for _, c := range m.connections {
|
for _, c := range m.connections {
|
||||||
if !m.matchesFilters(c) {
|
if !m.matchesFilters(c) {
|
||||||
@@ -176,10 +213,15 @@ func (m model) visibleConnections() []collector.Connection {
|
|||||||
if m.searchQuery != "" && !m.matchesSearch(c) {
|
if m.searchQuery != "" && !m.matchesSearch(c) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, c)
|
if m.isWatched(c.PID) {
|
||||||
|
watched = append(watched, c)
|
||||||
|
} else {
|
||||||
|
unwatched = append(unwatched, c)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
// watched connections appear first
|
||||||
|
return append(watched, unwatched...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) matchesFilters(c collector.Connection) bool {
|
func (m model) matchesFilters(c collector.Connection) bool {
|
||||||
@@ -218,3 +260,25 @@ func (m model) matchesSearch(c collector.Connection) bool {
|
|||||||
containsIgnoreCase(c.Proto, m.searchQuery) ||
|
containsIgnoreCase(c.Proto, m.searchQuery) ||
|
||||||
containsIgnoreCase(c.State, m.searchQuery)
|
containsIgnoreCase(c.State, m.searchQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) isWatched(pid int) bool {
|
||||||
|
if pid <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.watchedPIDs[pid]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) toggleWatch(pid int) {
|
||||||
|
if pid <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.watchedPIDs[pid] {
|
||||||
|
delete(m.watchedPIDs, pid)
|
||||||
|
} else {
|
||||||
|
m.watchedPIDs[pid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) watchedCount() int {
|
||||||
|
return len(m.watchedPIDs)
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
|
|||||||
indicator := " "
|
indicator := " "
|
||||||
if selected {
|
if selected {
|
||||||
indicator = m.theme.Styles.Success.Render("▸ ")
|
indicator = m.theme.Styles.Success.Render("▸ ")
|
||||||
|
} else if m.isWatched(c.PID) {
|
||||||
|
indicator = m.theme.Styles.Watched.Render("★ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
process := truncate(c.Process, cols.process)
|
process := truncate(c.Process, cols.process)
|
||||||
@@ -200,7 +202,18 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) renderStatusLine() string {
|
func (m model) renderStatusLine() string {
|
||||||
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state s sort / search ? help q quit")
|
// show status message if present
|
||||||
|
if m.statusMessage != "" {
|
||||||
|
return " " + m.theme.Styles.Warning.Render(m.statusMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
left := " " + m.theme.Styles.Normal.Render("t/u proto l/e/o state w watch K kill s sort / search ? help q quit")
|
||||||
|
|
||||||
|
// show watched count if any
|
||||||
|
if m.watchedCount() > 0 {
|
||||||
|
watchedInfo := fmt.Sprintf(" watching: %d", m.watchedCount())
|
||||||
|
left += m.theme.Styles.Watched.Render(watchedInfo)
|
||||||
|
}
|
||||||
|
|
||||||
return left
|
return left
|
||||||
}
|
}
|
||||||
@@ -233,6 +246,12 @@ func (m model) renderHelp() string {
|
|||||||
s cycle sort field
|
s cycle sort field
|
||||||
S reverse sort order
|
S reverse sort order
|
||||||
|
|
||||||
|
process management
|
||||||
|
──────────────────
|
||||||
|
w watch/unwatch process (highlight & track)
|
||||||
|
W clear all watched processes
|
||||||
|
K kill process (with confirmation)
|
||||||
|
|
||||||
other
|
other
|
||||||
─────
|
─────
|
||||||
/ search
|
/ search
|
||||||
@@ -286,6 +305,183 @@ func (m model) renderDetail() string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) renderKillModal() string {
|
||||||
|
if m.killTarget == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
c := m.killTarget
|
||||||
|
processName := c.Process
|
||||||
|
if processName == "" {
|
||||||
|
processName = "(unknown)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// count how many connections this process has
|
||||||
|
connCount := 0
|
||||||
|
for _, conn := range m.connections {
|
||||||
|
if conn.PID == c.PID {
|
||||||
|
connCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build modal content
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, m.theme.Styles.Error.Render(" !! KILL PROCESS? "))
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName)))
|
||||||
|
lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID))))
|
||||||
|
lines = append(lines, fmt.Sprintf(" user: %s", c.User))
|
||||||
|
lines = append(lines, fmt.Sprintf(" conns: %d", connCount))
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, m.theme.Styles.Warning.Render(" sends SIGTERM to process"))
|
||||||
|
if connCount > 1 {
|
||||||
|
lines = append(lines, m.theme.Styles.Warning.Render(fmt.Sprintf(" will close all %d connections", connCount)))
|
||||||
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s confirm %s cancel",
|
||||||
|
m.theme.Styles.Success.Render("[y]"),
|
||||||
|
m.theme.Styles.Error.Render("[n]")))
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) overlayModal(background, modal string) string {
|
||||||
|
bgLines := strings.Split(background, "\n")
|
||||||
|
modalLines := strings.Split(modal, "\n")
|
||||||
|
|
||||||
|
// find max modal line width using runewidth for proper unicode handling
|
||||||
|
modalWidth := 0
|
||||||
|
for _, line := range modalLines {
|
||||||
|
w := runeWidth(stripAnsi(line))
|
||||||
|
if w > modalWidth {
|
||||||
|
modalWidth = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modalWidth += 4 // padding for box
|
||||||
|
|
||||||
|
modalHeight := len(modalLines)
|
||||||
|
boxWidth := modalWidth + 2 // include border chars │ │
|
||||||
|
|
||||||
|
// calculate modal position (centered)
|
||||||
|
startRow := (m.height - modalHeight) / 2
|
||||||
|
if startRow < 2 {
|
||||||
|
startRow = 2
|
||||||
|
}
|
||||||
|
startCol := (m.width - boxWidth) / 2
|
||||||
|
if startCol < 0 {
|
||||||
|
startCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// build result
|
||||||
|
result := make([]string, len(bgLines))
|
||||||
|
copy(result, bgLines)
|
||||||
|
|
||||||
|
// ensure we have enough lines
|
||||||
|
for len(result) < startRow+modalHeight+2 {
|
||||||
|
result = append(result, strings.Repeat(" ", m.width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to build a line with modal overlay
|
||||||
|
buildLine := func(bgLine, modalContent string) string {
|
||||||
|
modalVisibleWidth := runeWidth(stripAnsi(modalContent))
|
||||||
|
endCol := startCol + modalVisibleWidth
|
||||||
|
|
||||||
|
leftBg := visibleSubstring(bgLine, 0, startCol)
|
||||||
|
rightBg := visibleSubstring(bgLine, endCol, m.width)
|
||||||
|
|
||||||
|
// pad left side if needed
|
||||||
|
leftLen := runeWidth(stripAnsi(leftBg))
|
||||||
|
if leftLen < startCol {
|
||||||
|
leftBg = leftBg + strings.Repeat(" ", startCol-leftLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftBg + modalContent + rightBg
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw top border
|
||||||
|
borderRow := startRow - 1
|
||||||
|
if borderRow >= 0 && borderRow < len(result) {
|
||||||
|
border := m.theme.Styles.Border.Render("╭" + strings.Repeat("─", modalWidth) + "╮")
|
||||||
|
result[borderRow] = buildLine(result[borderRow], border)
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw modal content with side borders
|
||||||
|
for i, line := range modalLines {
|
||||||
|
row := startRow + i
|
||||||
|
if row >= 0 && row < len(result) {
|
||||||
|
content := line
|
||||||
|
padding := modalWidth - runeWidth(stripAnsi(line))
|
||||||
|
if padding > 0 {
|
||||||
|
content = line + strings.Repeat(" ", padding)
|
||||||
|
}
|
||||||
|
boxedLine := m.theme.Styles.Border.Render("│") + content + m.theme.Styles.Border.Render("│")
|
||||||
|
result[row] = buildLine(result[row], boxedLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw bottom border
|
||||||
|
bottomRow := startRow + modalHeight
|
||||||
|
if bottomRow >= 0 && bottomRow < len(result) {
|
||||||
|
border := m.theme.Styles.Border.Render("╰" + strings.Repeat("─", modalWidth) + "╯")
|
||||||
|
result[bottomRow] = buildLine(result[bottomRow], border)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeWidth returns the display width of a string (assumes standard terminal chars)
|
||||||
|
func runeWidth(s string) int {
|
||||||
|
return len([]rune(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleSubstring extracts a substring by visible column positions, preserving ANSI codes
|
||||||
|
func visibleSubstring(s string, start, end int) string {
|
||||||
|
if start >= end {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
visiblePos := 0
|
||||||
|
inEscape := false
|
||||||
|
runes := []rune(s)
|
||||||
|
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
|
||||||
|
// detect start of ANSI escape sequence
|
||||||
|
if r == '\x1b' && i+1 < len(runes) && runes[i+1] == '[' {
|
||||||
|
inEscape = true
|
||||||
|
// always include ANSI codes so colors carry over
|
||||||
|
result.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inEscape {
|
||||||
|
// include escape sequence characters
|
||||||
|
result.WriteRune(r)
|
||||||
|
// check for end of escape sequence (letter)
|
||||||
|
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||||||
|
inEscape = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// regular visible character
|
||||||
|
if visiblePos >= start && visiblePos < end {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
visiblePos++
|
||||||
|
|
||||||
|
if visiblePos >= end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) scrollOffset(pageSize, total int) int {
|
func (m model) scrollOffset(pageSize, total int) int {
|
||||||
if total <= pageSize {
|
if total <= pageSize {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user