├── .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 | btail in action 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 | --------------------------------------------------------------------------------