diff --git a/src/tui/actions.go b/src/tui/actions.go index 51c4884..cbd348f 100644 --- a/src/tui/actions.go +++ b/src/tui/actions.go @@ -23,6 +23,10 @@ const ( ActionProcessRestart = ActionName("process_restart") ActionProcessScreen = ActionName("process_screen") ActionQuit = ActionName("quit") + ActionLogFind = ActionName("find") + ActionLogFindNext = ActionName("find_next") + ActionLogFindPrev = ActionName("find_prev") + ActionLogFindExit = ActionName("find_exit") ) var defaultShortcuts = map[ActionName]tcell.Key{ @@ -36,6 +40,10 @@ var defaultShortcuts = map[ActionName]tcell.Key{ ActionProcessRestart: tcell.KeyCtrlR, ActionProcessScreen: tcell.KeyF8, ActionQuit: tcell.KeyF10, + ActionLogFind: tcell.KeyCtrlF, + ActionLogFindNext: tcell.KeyCtrlN, + ActionLogFindPrev: tcell.KeyCtrlP, + ActionLogFindExit: tcell.KeyEsc, } type ShortCuts struct { @@ -191,6 +199,18 @@ func getDefaultActions() ShortCuts { ActionQuit: { Description: "Quit", }, + ActionLogFind: { + Description: "Find", + }, + ActionLogFindNext: { + Description: "Next", + }, + ActionLogFindPrev: { + Description: "Previous", + }, + ActionLogFindExit: { + Description: "Exit Search", + }, }, } for k, v := range sc.ShortCutKeys { diff --git a/src/tui/log-operations.go b/src/tui/log-operations.go index c8d1678..0d9006a 100644 --- a/src/tui/log-operations.go +++ b/src/tui/log-operations.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "github.com/f1bonacc1/glippy" "github.com/f1bonacc1/process-compose/src/app" "github.com/f1bonacc1/process-compose/src/types" @@ -12,9 +13,11 @@ func (pv *pcView) toggleLogSelection() { name := pv.getSelectedProcName() pv.logSelect = !pv.logSelect if pv.logSelect { - pv.logsTextArea.SetText(pv.logsText.GetText(true), true). + row, col := pv.logsText.GetScrollOffset() + pv.logsTextArea.SetText(pv.logsText.GetText(true), false). SetBorder(true). SetTitle(name + " [Select & Press Enter to Copy]") + pv.logsTextArea.SetOffset(row, col) } else { pv.logsTextArea.SetText("", false) } @@ -32,6 +35,7 @@ func (pv *pcView) toggleLogFollow() { } func (pv *pcView) startFollowLog(name string) { + pv.exitSearch() pv.logFollow = true pv.followLog(name) go pv.updateLogs() @@ -87,3 +91,11 @@ func (pv *pcView) createLogSelectionTextArea() { return nil }) } + +func (pv *pcView) getLogTitle(name string) string { + if pv.logsText.isSearchActive() { + return fmt.Sprintf("Find: %s [%d of %d] - %s", pv.logsText.getSearchTerm(), pv.logsText.getCurrentSearchIndex()+1, pv.logsText.getTotalSearchCount(), name) + } else { + return name + } +} diff --git a/src/tui/log-viewer.go b/src/tui/log-viewer.go index 53ee632..0e60588 100644 --- a/src/tui/log-viewer.go +++ b/src/tui/log-viewer.go @@ -1,24 +1,35 @@ package tui import ( + "bytes" "fmt" "github.com/f1bonacc1/process-compose/src/pclog" + "github.com/rivo/tview" "io" "math" + "regexp" + "strconv" "strings" "sync" +) - "github.com/rivo/tview" +var ( + regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) ) type LogView struct { tview.TextView - isWrapOn bool - buffer *strings.Builder - ansiWriter io.Writer - mx sync.Mutex - useAnsi bool - uniqueId string + isWrapOn bool + buffer *bytes.Buffer + ansiWriter io.Writer + mx sync.Mutex + useAnsi bool + uniqueId string + searchCurrentSelection int + isSearching bool + searchTerm string + searchIndex int + totalSearchCount int } func NewLogView(maxLines int) *LogView { @@ -28,10 +39,12 @@ func NewLogView(maxLines int) *LogView { TextView: *tview.NewTextView(). SetDynamicColors(true). SetScrollable(true). + SetRegions(true). SetMaxLines(maxLines), - buffer: &strings.Builder{}, - useAnsi: false, - uniqueId: pclog.GenerateUniqueID(10), + buffer: &bytes.Buffer{}, + useAnsi: false, + uniqueId: pclog.GenerateUniqueID(10), + searchCurrentSelection: 0, } l.ansiWriter = tview.ANSIWriter(l) l.SetBorder(true) @@ -84,10 +97,94 @@ func (l *LogView) Flush() { l.mx.Lock() defer l.mx.Unlock() if l.useAnsi { - l.ansiWriter.Write([]byte(l.buffer.String())) + l.ansiWriter.Write(l.buffer.Bytes()) } else { - l.Write([]byte(l.buffer.String())) + l.Write(l.buffer.Bytes()) } l.buffer.Reset() } + +func (l *LogView) addRegions(regex *regexp.Regexp, text string) string { + newText := regex.ReplaceAllStringFunc(text, func(match string) string { + region := fmt.Sprintf(`["%d"]%s[""]`, l.totalSearchCount, match) + l.totalSearchCount++ + return region + }) + + return newText +} + +func (l *LogView) removeRegions() { + text := regionPattern.ReplaceAllString(l.GetText(false), "") + l.SetText(text) +} + +func (l *LogView) searchString(search string, isRegex, caseSensitive bool) error { + if search == "" { + return nil + } + l.resetSearch() + searchRegexString := search + if !isRegex { + searchRegexString = regexp.QuoteMeta(searchRegexString) + } + if !caseSensitive { + searchRegexString = "(?i)" + searchRegexString + } + searchRegex, err := regexp.Compile(searchRegexString) + if err != nil { + return err + } + log := l.GetText(false) + l.SetText(l.addRegions(searchRegex, strings.TrimSpace(log))) + if l.totalSearchCount > 0 { + l.Highlight("0").ScrollToHighlight() + } + l.isSearching = true + l.searchTerm = search + return nil +} + +func (l *LogView) SearchNext() { + if l.totalSearchCount > 0 { + l.searchIndex = (l.searchIndex + 1) % l.totalSearchCount + l.Highlight(strconv.Itoa(l.searchIndex)).ScrollToHighlight() + } +} + +func (l *LogView) SearchPrev() { + if l.totalSearchCount > 0 { + l.searchIndex = (l.searchIndex - 1 + l.totalSearchCount) % l.totalSearchCount + l.Highlight(strconv.Itoa(l.searchIndex)).ScrollToHighlight() + } +} + +func (l *LogView) isSearchActive() bool { + return l.isSearching +} + +func (l *LogView) resetSearch() { + if l.isSearching { + l.isSearching = false + l.searchIndex = 0 + l.totalSearchCount = 0 + l.Highlight() + l.removeRegions() + } +} + +func (l *LogView) getSearchTerm() string { + return l.searchTerm +} + +func (l *LogView) getCurrentSearchIndex() int { + if l.totalSearchCount == 0 { + return -1 + } + return l.searchIndex +} + +func (l *LogView) getTotalSearchCount() int { + return l.totalSearchCount +} diff --git a/src/tui/proc-table.go b/src/tui/proc-table.go index f54e680..608d2f9 100644 --- a/src/tui/proc-table.go +++ b/src/tui/proc-table.go @@ -40,6 +40,8 @@ func (pv *pcView) onTableSelectionChange(row, column int) { if len(name) == 0 { return } + pv.logsText.resetSearch() + pv.updateHelpTextView() pv.logsText.SetBorder(true).SetTitle(name) pv.unFollowLog() pv.followLog(name) diff --git a/src/tui/search-form.go b/src/tui/search-form.go new file mode 100644 index 0000000..4f0d08a --- /dev/null +++ b/src/tui/search-form.go @@ -0,0 +1,54 @@ +package tui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (pv *pcView) showSearch() { + f := tview.NewForm() + f.SetCancelFunc(func() { + pv.pages.RemovePage(PageDialog) + }) + f.SetItemPadding(1) + f.SetBorder(true) + f.SetFieldBackgroundColor(tcell.ColorLightSkyBlue) + f.SetFieldTextColor(tcell.ColorBlack) + f.SetButtonsAlign(tview.AlignCenter) + f.SetTitle("Search Log") + f.AddInputField("Search For", pv.logsText.getSearchTerm(), 50, nil, nil) + f.AddCheckbox("Case Sensitive", false, nil) + f.AddCheckbox("Regex", false, nil) + searchFunc := func() { + searchTerm := f.GetFormItem(0).(*tview.InputField).GetText() + caseSensitive := f.GetFormItem(1).(*tview.Checkbox).IsChecked() + isRegex := f.GetFormItem(2).(*tview.Checkbox).IsChecked() + pv.stopFollowLog() + if err := pv.logsText.searchString(searchTerm, isRegex, caseSensitive); err != nil { + f.SetTitle(err.Error()) + return + } + pv.pages.RemovePage(PageDialog) + pv.logsText.SetTitle(pv.getLogTitle(pv.getSelectedProcName())) + pv.updateHelpTextView() + } + f.AddButton("Search", searchFunc) + f.AddButton("Cancel", func() { + pv.pages.RemovePage(PageDialog) + }) + f.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + searchFunc() + case tcell.KeyEsc: + pv.pages.RemovePage(PageDialog) + default: + return event + } + return nil + }) + f.SetFocus(0) + // Display and focus the dialog + pv.pages.AddPage(PageDialog, createDialogPage(f, 60, 11), true, true) + pv.appView.SetFocus(f) +} diff --git a/src/tui/view.go b/src/tui/view.go index daa8131..f2425cc 100644 --- a/src/tui/view.go +++ b/src/tui/view.go @@ -122,12 +122,29 @@ func (pv *pcView) onAppKey(event *tcell.EventKey) *tcell.EventKey { pv.terminateAppView() case pv.shortcuts.ShortCutKeys[ActionProcessInfo].key: pv.showInfo() + case pv.shortcuts.ShortCutKeys[ActionLogFind].key: + pv.showSearch() + case pv.shortcuts.ShortCutKeys[ActionLogFindNext].key: + pv.logsText.SearchNext() + pv.logsText.SetTitle(pv.getLogTitle(pv.getSelectedProcName())) + case pv.shortcuts.ShortCutKeys[ActionLogFindPrev].key: + pv.logsText.SearchPrev() + pv.logsText.SetTitle(pv.getLogTitle(pv.getSelectedProcName())) + case pv.shortcuts.ShortCutKeys[ActionLogFindExit].key: + pv.exitSearch() + default: return event } return nil } +func (pv *pcView) exitSearch() { + pv.logsText.resetSearch() + pv.logsText.SetTitle(pv.getLogTitle(pv.getSelectedProcName())) + pv.updateHelpTextView() +} + func (pv *pcView) terminateAppView() { m := tview.NewModal(). @@ -201,11 +218,20 @@ func (pv *pcView) updateHelpTextView() { logScrBool := pv.fullScrState != LogFull procScrBool := pv.fullScrState != ProcFull pv.helpText.Clear() + if pv.logsText.isSearchActive() { + pv.shortcuts.ShortCutKeys[ActionLogFind].writeButton(pv.helpText) + pv.shortcuts.ShortCutKeys[ActionLogFindNext].writeButton(pv.helpText) + pv.shortcuts.ShortCutKeys[ActionLogFindPrev].writeButton(pv.helpText) + pv.shortcuts.ShortCutKeys[ActionLogSelection].writeToggleButton(pv.helpText, !pv.logSelect) + pv.shortcuts.ShortCutKeys[ActionLogFindExit].writeButton(pv.helpText) + return + } fmt.Fprintf(pv.helpText, "%s ", "[lightskyblue:]LOGS:[-:-:-]") pv.shortcuts.ShortCutKeys[ActionLogScreen].writeToggleButton(pv.helpText, logScrBool) pv.shortcuts.ShortCutKeys[ActionFollowLog].writeToggleButton(pv.helpText, !pv.logFollow) pv.shortcuts.ShortCutKeys[ActionWrapLog].writeToggleButton(pv.helpText, !pv.logsText.IsWrapOn()) pv.shortcuts.ShortCutKeys[ActionLogSelection].writeToggleButton(pv.helpText, !pv.logSelect) + pv.shortcuts.ShortCutKeys[ActionLogFind].writeButton(pv.helpText) fmt.Fprintf(pv.helpText, "%s ", "[lightskyblue::b]PROCESS:[-:-:-]") pv.shortcuts.ShortCutKeys[ActionProcessInfo].writeButton(pv.helpText) pv.shortcuts.ShortCutKeys[ActionProcessStart].writeButton(pv.helpText)