├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── LICENSE
├── README.md
├── assets
└── demo.gif
├── cmd
└── btail
│ └── main.go
├── go.mod
├── go.sum
└── pkg
├── app
├── app.go
└── styles.go
├── config
└── config.go
└── tail
└── tail.go
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release Binaries
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v[0-9]+.[0-9]+.[0-9]+*'
7 |
8 | jobs:
9 | release:
10 | name: Release Binaries
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout main branch
14 | uses: actions/checkout@v3
15 | with:
16 | ref: main
17 |
18 | - name: Set up Go
19 | uses: actions/setup-go@v4
20 | with:
21 | go-version: "1.21"
22 |
23 | - name: Run tests
24 | run: go test ./...
25 |
26 | - name: Build binaries
27 | run: |
28 | GOOS=linux GOARCH=amd64 go build -o btail-linux-amd64 cmd/btail/main.go
29 | GOOS=linux GOARCH=arm64 go build -o btail-linux-arm64 cmd/btail/main.go
30 | GOOS=darwin GOARCH=amd64 go build -o btail-darwin-amd64 cmd/btail/main.go
31 | GOOS=darwin GOARCH=arm64 go build -o btail-darwin-arm64 cmd/btail/main.go
32 |
33 | - name: Create Releases
34 | id: create_releases
35 | env:
36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37 | run: |
38 | gh release create ${{ github.ref_name }} \
39 | --title "${{ github.ref_name }}" \
40 | btail-linux-amd64 \
41 | btail-linux-arm64 \
42 | btail-darwin-amd64 \
43 | btail-darwin-arm64
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 | go.work.sum
23 |
24 | .idea
25 | .vscode
26 |
27 | .DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Mohammed Galalen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # btail 🐝 (beautiful) - Interactive File Tail Viewer
2 |
3 | btail is a command-line utility for viewing the tail of files with an interactive terminal user interface. It allows you to monitor log files in real-time with features like live updates, search functionality, syntax highlighting, and easy navigation.
4 |
5 |
6 |
7 | ## Binary Files
8 | Download binary files from [releases](https://github.com/galalen/btail/releases)
9 |
10 | ## Installation
11 | Make sure you have [go](https://go.dev/) installed on your system
12 | ```bash
13 | go install github.com/galalen/btail@latest
14 | ```
15 |
16 | ## Usage
17 | Basic usage:
18 | Options:
19 | - `-n `: Set the number of lines to display (default: 5)
20 | - `-f`: Enable follow mode to watch for new lines
21 |
22 | Examples:
23 | ```bash
24 | btail -n 10 -f path/to/file.log
25 | ```
26 |
27 | ## Special Thanks
28 |
29 | - [nxadm/tail](https://github.com/nxadm/tail) and [grafana/tail](https://github.com/grafana/tail): For inspiring the core file tailing functionality.
30 | - [bubbletea](https://github.com/charmbracelet/bubbletea): For powering the interactive terminal UI.
31 |
32 | ## Contributing
33 |
34 | Contributions are welcome! Please feel free to submit a Pull Request.
35 |
36 | ## License
37 |
38 | [MIT License]
39 |
--------------------------------------------------------------------------------
/assets/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galalen/btail/92e3d128a57f2cc54f7293a8cd8ff56c38d7260d/assets/demo.gif
--------------------------------------------------------------------------------
/cmd/btail/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/galalen/btail/pkg/app"
10 | "github.com/galalen/btail/pkg/config"
11 | "github.com/galalen/btail/pkg/tail"
12 | )
13 |
14 | func main() {
15 | lines := flag.Int("n", 10, "number of lines to display")
16 | follow := flag.Bool("f", false, "follow the file for new lines")
17 | flag.Parse()
18 |
19 | if len(flag.Args()) < 1 {
20 | fmt.Println("Error: Please provide a filename")
21 | os.Exit(1)
22 | }
23 | filename := flag.Args()[0]
24 |
25 | cfg := config.Config{
26 | Lines: *lines,
27 | Follow: *follow,
28 | UIBufferSize: 500,
29 | BufferSize: 500,
30 | }
31 |
32 | t, err := tail.TailFile(filename, cfg)
33 | if err != nil {
34 | fmt.Printf("Error: %s\n", err)
35 | os.Exit(1)
36 | }
37 |
38 | if err := app.Run(t); err != nil {
39 | log.Fatalf("Error running UI: %v", err)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/galalen/btail
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.0
6 |
7 | require (
8 | github.com/charmbracelet/bubbles v0.21.0
9 | github.com/charmbracelet/bubbletea v1.3.5
10 | github.com/charmbracelet/lipgloss v1.1.0
11 | github.com/fsnotify/fsnotify v1.9.0
12 | )
13 |
14 | require (
15 | github.com/atotto/clipboard v0.1.4 // indirect
16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
17 | github.com/charmbracelet/colorprofile v0.3.1 // indirect
18 | github.com/charmbracelet/x/ansi v0.9.2 // indirect
19 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
20 | github.com/charmbracelet/x/term v0.2.1 // indirect
21 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
23 | github.com/mattn/go-isatty v0.0.20 // indirect
24 | github.com/mattn/go-localereader v0.0.1 // indirect
25 | github.com/mattn/go-runewidth v0.0.16 // indirect
26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
27 | github.com/muesli/cancelreader v0.2.2 // indirect
28 | github.com/muesli/termenv v0.16.0 // indirect
29 | github.com/rivo/uniseg v0.4.7 // indirect
30 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
31 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
32 | golang.org/x/sync v0.14.0 // indirect
33 | golang.org/x/sys v0.33.0 // indirect
34 | golang.org/x/text v0.25.0 // indirect
35 | )
36 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
7 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
8 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
9 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
10 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
11 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
12 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
13 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
14 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
15 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
16 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
19 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
20 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
21 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
22 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
23 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
24 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
25 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
26 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
27 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
28 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
29 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
30 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
32 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
33 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
34 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
35 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
36 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
37 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
38 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
39 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
40 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
41 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
42 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
43 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
44 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
45 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
46 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
47 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
48 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
49 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
50 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
51 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
52 |
--------------------------------------------------------------------------------
/pkg/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strings"
7 | "time"
8 |
9 | "github.com/charmbracelet/bubbles/textinput"
10 | "github.com/charmbracelet/bubbles/viewport"
11 | tea "github.com/charmbracelet/bubbletea"
12 | "github.com/charmbracelet/lipgloss"
13 | "github.com/galalen/btail/pkg/tail"
14 | )
15 |
16 | type State int
17 | type SearchMode string
18 |
19 | const (
20 | StateNormal State = iota
21 | StateSearching
22 | SearchModeNormal SearchMode = "normal"
23 | SearchModeRegex SearchMode = "regex"
24 | viewportWidth = 80
25 | viewportHeight = 20
26 | )
27 |
28 | type Model struct {
29 | tailer *tail.Tail
30 | logsView viewport.Model
31 | searchInput textinput.Model
32 | bufferedLines []tail.Line
33 | state State
34 | searchMode SearchMode
35 | compiledRegex *regexp.Regexp
36 | searchTerm string
37 | width int
38 | height int
39 | matchCount int
40 | lastScrollPos int
41 | autoScroll bool
42 | }
43 |
44 | func NewModel(tailer *tail.Tail) *Model {
45 | vp := viewport.New(viewportWidth, viewportHeight)
46 | vp.Style = baseStyle
47 |
48 | ti := textinput.New()
49 | ti.Placeholder = "search..."
50 |
51 | return &Model{
52 | tailer: tailer,
53 | logsView: vp,
54 | searchInput: ti,
55 | state: StateNormal,
56 | searchMode: SearchModeNormal,
57 | autoScroll: true,
58 | bufferedLines: make([]tail.Line, 0, tailer.Config.UIBufferSize),
59 | }
60 | }
61 |
62 | func (m *Model) Init() tea.Cmd {
63 | return m.tailFile()
64 | }
65 |
66 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67 | var cmds []tea.Cmd
68 |
69 | switch msg := msg.(type) {
70 | case tea.KeyMsg:
71 | switch msg.String() {
72 | case "q":
73 | if m.state == StateNormal {
74 | return m, tea.Quit
75 | }
76 | case "ctrl+c":
77 | return m, tea.Quit
78 | case "ctrl+f":
79 | m.searchInput.Placeholder = "search..."
80 | cmd := m.toggleSearch(SearchModeNormal)
81 | if cmd != nil {
82 | cmds = append(cmds, cmd)
83 | }
84 | case "ctrl+r":
85 | m.searchInput.Placeholder = "pattern..."
86 | cmd := m.toggleSearch(SearchModeRegex)
87 | if cmd != nil {
88 | cmds = append(cmds, cmd)
89 | }
90 | case "esc":
91 | cmd := m.exitSearch()
92 | if cmd != nil {
93 | cmds = append(cmds, cmd)
94 | }
95 | case "up":
96 | m.scrollUp()
97 | case "down":
98 | m.scrollDown()
99 | case "home":
100 | m.scrollToTop()
101 | case "end":
102 | m.scrollToBottom()
103 | case "c":
104 | if m.state != StateSearching {
105 | m.clearBuffer()
106 | }
107 | }
108 | case tea.WindowSizeMsg:
109 | m.handleWindowResize(msg)
110 | case tail.Line:
111 | m.handleNewLine(msg)
112 | cmds = append(cmds, m.tailFile())
113 | case error:
114 | // TODO: show error in status bar
115 | cmds = append(cmds, m.tailFile())
116 | }
117 |
118 | if m.state == StateSearching {
119 | var cmd tea.Cmd
120 | m.searchInput, cmd = m.searchInput.Update(msg)
121 | if cmd != nil {
122 | cmds = append(cmds, cmd)
123 | }
124 | m.searchTerm = m.searchInput.Value()
125 | if m.searchMode == SearchModeRegex {
126 | if m.searchTerm != "" {
127 | m.compiledRegex, _ = regexp.Compile(m.searchTerm)
128 | }
129 | } else {
130 | m.compiledRegex = regexp.MustCompile(`(?i)` + regexp.QuoteMeta(m.searchTerm))
131 | }
132 | m.updateContent()
133 | }
134 |
135 | m.updateScrollState()
136 | var cmd tea.Cmd
137 | m.logsView, cmd = m.logsView.Update(msg)
138 | if cmd != nil {
139 | cmds = append(cmds, cmd)
140 | }
141 |
142 | return m, tea.Batch(cmds...)
143 | }
144 |
145 | func (m *Model) clearBuffer() {
146 | m.bufferedLines = make([]tail.Line, 0, m.tailer.Config.UIBufferSize)
147 | m.updateContent()
148 | }
149 |
150 | func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) {
151 | m.width = msg.Width
152 | m.height = msg.Height
153 | m.logsView.Width = msg.Width
154 | m.logsView.Height = msg.Height - 6
155 | m.searchInput.Width = msg.Width / 3
156 | }
157 |
158 | func (m *Model) handleNewLine(line tail.Line) {
159 | m.bufferedLines = append(m.bufferedLines, line)
160 | if len(m.bufferedLines) > m.tailer.Config.UIBufferSize {
161 | m.bufferedLines = m.bufferedLines[1:]
162 | }
163 | m.updateContent()
164 | }
165 |
166 | func (m *Model) View() string {
167 | return lipgloss.JoinVertical(
168 | lipgloss.Center,
169 | titleStyle.Render("btail 🐝"),
170 | m.logsView.View(),
171 | m.renderStatusBar(),
172 | )
173 | }
174 |
175 | func (m *Model) renderBufferInfo() string {
176 | return fmt.Sprintf("buffer: %d/%d", len(m.bufferedLines), m.tailer.Config.UIBufferSize)
177 | }
178 |
179 | func (m *Model) renderStatusBar() string {
180 | if m.state == StateSearching {
181 | return m.renderSearchBar()
182 | }
183 | return statusBarStyle.Render(fmt.Sprintf("\t%s | ctrl+f: search | ctrl+r: regex | c: clear | q: quit\t", m.renderBufferInfo()))
184 | }
185 |
186 | func (m *Model) renderSearchMode() string {
187 | return searchModeStyle.Render(fmt.Sprintf("mode: %s", m.searchMode))
188 | }
189 |
190 | func (m *Model) renderSearchBar() string {
191 | return lipgloss.JoinHorizontal(
192 | lipgloss.Left,
193 | searchInputStyle.Render(m.searchInput.View()),
194 | pinkStyle.Render(fmt.Sprintf(" %d matches | ", m.matchCount)),
195 | m.renderSearchMode(),
196 | pinkStyle.Render(fmt.Sprintf(" | %s | esc: cancel", m.renderBufferInfo())),
197 | )
198 | }
199 |
200 | func (m *Model) highlightSearch(content string) string {
201 | if m.compiledRegex == nil {
202 | return content
203 | }
204 |
205 | return m.compiledRegex.ReplaceAllStringFunc(content, func(match string) string {
206 | return searchMatchStyle.Render(match)
207 | })
208 | }
209 |
210 | func (m *Model) updateContent() {
211 | var content strings.Builder
212 | m.matchCount = 0
213 |
214 | for _, line := range m.bufferedLines {
215 | highlightedContent := highlightPatterns(line.Text)
216 |
217 | if m.searchMode == SearchModeNormal {
218 | if m.searchTerm != "" {
219 | count := strings.Count(strings.ToLower(line.Text), strings.ToLower(m.searchTerm))
220 | if count > 0 {
221 | m.matchCount += count
222 | highlightedContent = m.highlightSearch(line.Text)
223 | }
224 | }
225 | } else {
226 | if m.compiledRegex != nil {
227 | count := len(m.compiledRegex.FindAllStringIndex(line.Text, -1))
228 | if count > 0 {
229 | m.matchCount += count
230 | highlightedContent = m.highlightSearch(line.Text)
231 | }
232 | }
233 | }
234 |
235 | timestamp := fmt.Sprintf("%s%s%s",
236 | bracketsStyle.Render("["),
237 | timeStyle.Render(line.Time.Format("03:04:05 PM")),
238 | bracketsStyle.Render("]"),
239 | )
240 | logLine := lipgloss.NewStyle().Width(m.logsView.Width).Render(fmt.Sprintf("%s %s", timestamp, highlightedContent))
241 | content.WriteString(logLine + "\n")
242 | }
243 |
244 | m.logsView.SetContent(content.String())
245 | if m.autoScroll {
246 | m.logsView.GotoBottom()
247 | }
248 | }
249 |
250 | func (m *Model) scrollUp() {
251 | m.autoScroll = false
252 | m.logsView.ScrollUp(1)
253 | }
254 |
255 | func (m *Model) scrollDown() {
256 | m.logsView.ScrollDown(1)
257 | if m.logsView.AtBottom() {
258 | m.autoScroll = true
259 | }
260 | }
261 |
262 | func (m *Model) scrollToTop() {
263 | m.autoScroll = false
264 | m.logsView.GotoTop()
265 | }
266 |
267 | func (m *Model) scrollToBottom() {
268 | m.autoScroll = true
269 | m.logsView.GotoBottom()
270 | }
271 |
272 | func (m *Model) toggleSearch(searchMode SearchMode) tea.Cmd {
273 | if m.state == StateSearching {
274 | return m.exitSearch()
275 | }
276 |
277 | m.state = StateSearching
278 | m.searchMode = searchMode
279 | m.searchInput.Focus()
280 | m.searchInput.SetValue("")
281 | m.searchTerm = ""
282 | m.compiledRegex = nil
283 | m.matchCount = 0
284 | m.updateContent()
285 | return textinput.Blink
286 | }
287 |
288 | func (m *Model) exitSearch() tea.Cmd {
289 | if m.state == StateSearching {
290 | m.state = StateNormal
291 | m.searchInput.Blur()
292 | m.searchInput.SetValue("")
293 | m.searchTerm = ""
294 | m.compiledRegex = nil
295 | m.matchCount = 0
296 | m.updateContent()
297 | }
298 | return nil
299 | }
300 |
301 | func (m *Model) tailFile() tea.Cmd {
302 | return func() tea.Msg {
303 | select {
304 | case line, ok := <-m.tailer.Lines:
305 | if !ok {
306 | return nil
307 | }
308 | return line
309 | case <-time.After(100 * time.Millisecond):
310 | if m.tailer.Config.Follow {
311 | return m.tailFile()()
312 | }
313 | return nil
314 | }
315 | }
316 | }
317 |
318 | func (m *Model) updateScrollState() {
319 | if m.logsView.YOffset != m.lastScrollPos {
320 | m.autoScroll = m.logsView.AtBottom()
321 | m.lastScrollPos = m.logsView.YOffset
322 | }
323 | }
324 |
325 | func Run(tailer *tail.Tail) error {
326 | p := tea.NewProgram(NewModel(tailer), tea.WithAltScreen())
327 | _, err := p.Run()
328 | return err
329 | }
330 |
--------------------------------------------------------------------------------
/pkg/app/styles.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "regexp"
5 |
6 | "github.com/charmbracelet/lipgloss"
7 | )
8 |
9 | var (
10 | blueColor = lipgloss.Color("#00afd7")
11 | pinkColor = lipgloss.Color("#ff5faf")
12 | yellowColor = lipgloss.Color("#ffff87")
13 | orangeColor = lipgloss.Color("#ff8700")
14 | redColor = lipgloss.Color("#ff0000")
15 | tealColor = lipgloss.Color("#5f87ff")
16 | lightGrayColor = lipgloss.Color("#D3D3D3")
17 | darkerGrayColor = lipgloss.Color("#3a3a3a")
18 | silverColor = lipgloss.Color("#1b1b1b")
19 | greenColor = lipgloss.Color("#00ff00")
20 | lightGreenColor = lipgloss.Color("#90ee90")
21 |
22 | baseStyle = lipgloss.NewStyle().
23 | Margin(0).
24 | Padding(0).
25 | BorderStyle(lipgloss.NormalBorder()).
26 | BorderForeground(darkerGrayColor)
27 |
28 | titleStyle = lipgloss.NewStyle().
29 | Bold(true).
30 | Foreground(yellowColor).
31 | Padding(0, 1)
32 |
33 | statusBarStyle = lipgloss.NewStyle().
34 | Bold(false).
35 | Foreground(pinkColor).
36 | Background(silverColor)
37 |
38 | searchInputStyle = lipgloss.NewStyle().
39 | Foreground(pinkColor)
40 |
41 | pinkStyle = lipgloss.NewStyle().
42 | Foreground(pinkColor)
43 |
44 | searchMatchStyle = lipgloss.NewStyle().
45 | Foreground(darkerGrayColor).
46 | Background(pinkColor).
47 | Bold(true)
48 |
49 | timeStyle = lipgloss.NewStyle().
50 | Foreground(lightGreenColor)
51 |
52 | ipStyle = lipgloss.NewStyle().
53 | Foreground(blueColor)
54 |
55 | urlStyle = lipgloss.NewStyle().
56 | Foreground(tealColor).
57 | Underline(true)
58 |
59 | methodStyle = lipgloss.NewStyle().
60 | Bold(true).
61 | Foreground(yellowColor)
62 |
63 | bracketsStyle = lipgloss.NewStyle().
64 | Foreground(yellowColor)
65 |
66 | searchModeStyle = lipgloss.NewStyle().
67 | Foreground(lightGreenColor)
68 |
69 | ipRegex = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}(:\d{1,5})?\b`)
70 | urlRegex = regexp.MustCompile(`\b(?:https?|ftp|rtmp|smtp)://\S+`)
71 | methodRegex = regexp.MustCompile(`\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b`)
72 | errorRegex = regexp.MustCompile(`(?i)\b(error|exception|failed|failure|timeout|denied)\b`)
73 | warningRegex = regexp.MustCompile(`(?i)\b(warning|warn|deprecated)\b`)
74 | )
75 |
76 | func highlightPatterns(text string) string {
77 | text = ipRegex.ReplaceAllStringFunc(text, func(ip string) string {
78 | return ipStyle.Render(ip)
79 | })
80 |
81 | text = urlRegex.ReplaceAllStringFunc(text, func(url string) string {
82 | return urlStyle.Render(url)
83 | })
84 |
85 | text = methodRegex.ReplaceAllStringFunc(text, func(method string) string {
86 | return methodStyle.Render(method)
87 | })
88 |
89 | text = errorRegex.ReplaceAllStringFunc(text, func(match string) string {
90 | return lipgloss.NewStyle().Foreground(redColor).Bold(true).Render(match)
91 | })
92 |
93 | text = warningRegex.ReplaceAllStringFunc(text, func(match string) string {
94 | return lipgloss.NewStyle().Foreground(orangeColor).Render(match)
95 | })
96 |
97 | return text
98 | }
99 |
--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Config struct {
4 | Lines int
5 | Follow bool
6 | UIBufferSize int
7 | BufferSize int
8 | }
9 |
10 | func NewDefaultConfig() Config {
11 | return Config{
12 | Lines: 10,
13 | Follow: false,
14 | UIBufferSize: 500,
15 | BufferSize: 500,
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/pkg/tail/tail.go:
--------------------------------------------------------------------------------
1 | package tail
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | "log"
8 | "math"
9 | "os"
10 | "strings"
11 | "time"
12 |
13 | "github.com/fsnotify/fsnotify"
14 | "github.com/galalen/btail/pkg/config"
15 | )
16 |
17 | type Line struct {
18 | Text string
19 | Time time.Time
20 | Error error
21 | }
22 |
23 | type Tail struct {
24 | Filename string
25 | Lines chan Line
26 | Config config.Config
27 | file *os.File
28 | fileSize int64
29 | watcher *fsnotify.Watcher
30 | done chan struct{}
31 | }
32 |
33 | func TailFile(Filename string, cfg config.Config) (*Tail, error) {
34 | if cfg.Lines <= 0 {
35 | cfg.Lines = 10
36 | }
37 |
38 | t := &Tail{
39 | Filename: Filename,
40 | Lines: make(chan Line, cfg.BufferSize),
41 | Config: cfg,
42 | done: make(chan struct{}),
43 | }
44 | var err error
45 |
46 | if err = t.openFile(); err != nil {
47 | return nil, err
48 | }
49 |
50 | if cfg.Follow {
51 | t.watcher, err = fsnotify.NewWatcher()
52 | if err != nil {
53 | t.file.Close()
54 | return nil, fmt.Errorf("failed to create watcher: %v", err)
55 | }
56 |
57 | err = t.watcher.Add(Filename)
58 | if err != nil {
59 | t.file.Close()
60 | t.watcher.Close()
61 | return nil, fmt.Errorf("failed to add file to watcher: %v", err)
62 | }
63 | }
64 |
65 | go t.tail()
66 |
67 | return t, nil
68 | }
69 |
70 | func (t *Tail) openFile() error {
71 | file, err := os.Open(t.Filename)
72 | if err != nil {
73 | return fmt.Errorf("failed to open file %s: %v", t.Filename, err)
74 | }
75 | t.file = file
76 |
77 | info, err := file.Stat()
78 | if err != nil {
79 | t.file.Close()
80 | return fmt.Errorf("failed to stat file %s: %v", t.Filename, err)
81 | }
82 | t.fileSize = info.Size()
83 |
84 | return nil
85 | }
86 |
87 | func (t *Tail) reopen() {
88 | var err error
89 | for {
90 | t.file, err = os.Open(t.Filename)
91 | if err != nil {
92 | if os.IsNotExist(err) {
93 | time.Sleep(time.Second)
94 | }
95 | } else {
96 | break
97 | }
98 |
99 | time.Sleep(250 * time.Millisecond)
100 | }
101 | }
102 |
103 | func (t *Tail) tail() {
104 | defer close(t.Lines)
105 | defer t.file.Close()
106 |
107 | if t.watcher != nil {
108 | defer t.watcher.Close()
109 | }
110 |
111 | lines, err := t.readLastNLines()
112 | if err != nil {
113 | log.Printf("failed to read Lines from file: %v", err)
114 | return
115 | }
116 |
117 | for _, line := range lines {
118 | t.Lines <- line
119 | }
120 |
121 | if t.Config.Follow {
122 | if err := t.followFile(); err != nil {
123 | log.Printf("failed to follow file: %v", err)
124 | }
125 | }
126 | }
127 |
128 | func (t *Tail) readLastNLines() ([]Line, error) {
129 | size := int(math.Pow(1024, 2))
130 | buffer := make([]byte, size)
131 |
132 | offset := t.fileSize
133 | lineCount := 0
134 | lines := make([]Line, 0, t.Config.Lines)
135 |
136 | for offset > 0 && lineCount < t.Config.Lines {
137 | readSize := int64(len(buffer))
138 | if offset < readSize {
139 | readSize = offset
140 | }
141 | offset -= readSize
142 |
143 | _, err := t.file.Seek(offset, io.SeekStart)
144 | if err != nil {
145 | return nil, err
146 | }
147 |
148 | bytesRead, err := t.file.Read(buffer[:readSize])
149 | if err != nil && err != io.EOF {
150 | return nil, err
151 | }
152 |
153 | for i := bytesRead - 1; i >= 0; i-- {
154 | if buffer[i] == '\n' {
155 | lineCount++
156 | if lineCount > t.Config.Lines {
157 | offset += int64(i) + 1
158 | break
159 | }
160 | }
161 | }
162 | }
163 |
164 | _, err := t.file.Seek(offset, io.SeekStart)
165 | if err != nil {
166 | return nil, err
167 | }
168 |
169 | scanner := bufio.NewScanner(t.file)
170 | for scanner.Scan() && len(lines) < t.Config.Lines {
171 | lines = append(lines, Line{Text: scanner.Text(), Time: time.Now()})
172 | }
173 |
174 | return lines, scanner.Err()
175 | }
176 |
177 | func (t *Tail) readNewLines(reader *bufio.Reader) {
178 | for {
179 | line, err := reader.ReadString('\n')
180 | if err != nil {
181 | if err == io.EOF {
182 | break
183 | }
184 | t.Lines <- Line{Error: err}
185 | return
186 | }
187 | t.Lines <- Line{Text: strings.TrimSuffix(line, "\n"), Time: time.Now()}
188 | t.fileSize += int64(len(line))
189 | }
190 | }
191 |
192 | func (t *Tail) followFile() error {
193 | reader := bufio.NewReader(t.file)
194 |
195 | for {
196 | select {
197 | case event, ok := <-t.watcher.Events:
198 | if !ok {
199 | return nil
200 | }
201 | if event.Op&fsnotify.Write == fsnotify.Write {
202 | t.readNewLines(reader)
203 | }
204 |
205 | if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename {
206 | // TODO: handle this probably (show in status bar)
207 | _ = t.watcher.Remove(t.Filename)
208 | t.reopen()
209 | _ = t.watcher.Add(t.Filename)
210 |
211 | reader = bufio.NewReader(t.file)
212 | }
213 | case _, ok := <-t.watcher.Errors:
214 | if !ok {
215 | return nil
216 | }
217 | // show error in info area
218 | case <-t.done:
219 | return nil
220 | }
221 | }
222 | }
223 |
224 | func (t *Tail) Stop() {
225 | close(t.done)
226 | }
227 |
--------------------------------------------------------------------------------