From bf0b2924f1dbdae6ccac628b9b76c21b9c1bf39f Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Mon, 4 Aug 2025 14:12:34 +0530 Subject: [PATCH 01/27] new tui work: basic scaffolding for a new tui old tui has been moved to a folder called and has been gitignored for cleanliness --- .gitignore | 6 + internal/tui/app/model.go | 186 ++---------- internal/tui/components/collection_item.go | 36 --- internal/tui/components/endpoint_item.go | 41 --- internal/tui/components/form.go | 144 --------- internal/tui/components/keyvalue_editor.go | 260 ----------------- internal/tui/components/layout.go | 146 --------- internal/tui/components/paginated_list.go | 119 -------- internal/tui/components/text_input.go | 107 ------- internal/tui/components/textarea.go | 311 -------------------- internal/tui/styles/colors.go | 21 -- internal/tui/styles/layout.go | 41 --- internal/tui/views/add_collection.go | 140 --------- internal/tui/views/collections.go | 235 --------------- internal/tui/views/edit_collection.go | 113 ------- internal/tui/views/endpoint_sidebar.go | 179 ------------ internal/tui/views/request_builder.go | 325 --------------------- internal/tui/views/selected_collection.go | 276 ----------------- main.go | 16 +- 19 files changed, 32 insertions(+), 2670 deletions(-) delete mode 100644 internal/tui/components/collection_item.go delete mode 100644 internal/tui/components/endpoint_item.go delete mode 100644 internal/tui/components/form.go delete mode 100644 internal/tui/components/keyvalue_editor.go delete mode 100644 internal/tui/components/layout.go delete mode 100644 internal/tui/components/paginated_list.go delete mode 100644 internal/tui/components/text_input.go delete mode 100644 internal/tui/components/textarea.go delete mode 100644 internal/tui/styles/colors.go delete mode 100644 internal/tui/styles/layout.go delete mode 100644 internal/tui/views/add_collection.go delete mode 100644 internal/tui/views/collections.go delete mode 100644 internal/tui/views/edit_collection.go delete mode 100644 internal/tui/views/endpoint_sidebar.go delete mode 100644 internal/tui/views/request_builder.go delete mode 100644 internal/tui/views/selected_collection.go diff --git a/.gitignore b/.gitignore index 3e0620d..72c82db 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ testdata/ .pyssg/ __pycache__/ *.pyc + +# build stuff +build/ + +# old tui +internal/old_tui/ diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 5c46f69..58d8c9f 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -1,186 +1,36 @@ package app import ( - "context" - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/log" - "github.com/maniac-en/req/internal/tui/views" ) -type ViewMode int - -const ( - CollectionsViewMode ViewMode = iota - AddCollectionViewMode - EditCollectionViewMode - SelectedCollectionViewMode -) - -type Model struct { - ctx *Context - mode ViewMode - collectionsView views.CollectionsView - addCollectionView views.AddCollectionView - editCollectionView views.EditCollectionView - selectedCollectionView views.SelectedCollectionView - width int - height int - selectedIndex int -} - -func NewModel(ctx *Context) Model { - collectionsView := views.NewCollectionsView(ctx.Collections) - if ctx.DummyDataCreated { - collectionsView.SetDummyDataNotification(true) - } - - m := Model{ - ctx: ctx, - mode: CollectionsViewMode, - collectionsView: collectionsView, - addCollectionView: views.NewAddCollectionView(ctx.Collections), - } - return m +type AppModel struct { + width int + height int } -func (m Model) Init() tea.Cmd { - return m.collectionsView.Init() +func (a AppModel) Init() tea.Cmd { + return nil } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - +func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: - isFiltering := m.mode == CollectionsViewMode && m.collectionsView.IsFiltering() - - if !isFiltering { - switch msg.String() { - case "ctrl+c", "q": - if m.mode == CollectionsViewMode { - return m, tea.Quit - } - m.mode = CollectionsViewMode - return m, nil - case "a": - if m.mode == CollectionsViewMode { - m.selectedIndex = m.collectionsView.GetSelectedIndex() - m.mode = AddCollectionViewMode - if m.width > 0 && m.height > 0 { - sizeMsg := tea.WindowSizeMsg{Width: m.width, Height: m.height} - m.addCollectionView, _ = m.addCollectionView.Update(sizeMsg) - } - return m, nil - } - case "enter": - if m.mode == CollectionsViewMode { - if selectedItem := m.collectionsView.GetSelectedItem(); selectedItem != nil { - m.selectedIndex = m.collectionsView.GetSelectedIndex() - m.mode = SelectedCollectionViewMode - if m.width > 0 && m.height > 0 { - m.selectedCollectionView = views.NewSelectedCollectionViewWithSize(m.ctx.Endpoints, m.ctx.HTTP, *selectedItem, m.width, m.height) - } else { - m.selectedCollectionView = views.NewSelectedCollectionView(m.ctx.Endpoints, m.ctx.HTTP, *selectedItem) - } - return m, m.selectedCollectionView.Init() - } else { - log.Error("issue getting currently selected collection") - } - } - case "e": - if m.mode == CollectionsViewMode { - if selectedItem := m.collectionsView.GetSelectedItem(); selectedItem != nil { - m.selectedIndex = m.collectionsView.GetSelectedIndex() - m.mode = EditCollectionViewMode - m.editCollectionView = views.NewEditCollectionView(m.ctx.Collections, *selectedItem) - if m.width > 0 && m.height > 0 { - sizeMsg := tea.WindowSizeMsg{Width: m.width, Height: m.height} - m.editCollectionView, _ = m.editCollectionView.Update(sizeMsg) - } - return m, nil - } else { - log.Error("issue getting currently selected collection") - } - } - case "x": - if m.mode == CollectionsViewMode { - if selectedItem := m.collectionsView.GetSelectedItem(); selectedItem != nil { - return m, func() tea.Msg { - err := m.ctx.Collections.Delete(context.Background(), selectedItem.ID) - if err != nil { - return views.CollectionDeleteErrorMsg{Err: err} - } - return views.CollectionDeletedMsg{ID: selectedItem.ID} - } - } - } - } - } case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - if m.mode == CollectionsViewMode && !m.collectionsView.IsInitialized() { - m.collectionsView = views.NewCollectionsViewWithSize(m.ctx.Collections, m.width, m.height) - if m.ctx.DummyDataCreated { - m.collectionsView.SetDummyDataNotification(true) - } - return m, m.collectionsView.Init() - } - if m.mode == CollectionsViewMode { - m.collectionsView, _ = m.collectionsView.Update(msg) - } - case views.BackToCollectionsMsg: - m.mode = CollectionsViewMode - if m.width > 0 && m.height > 0 { - m.collectionsView = views.NewCollectionsViewWithSize(m.ctx.Collections, m.width, m.height) - if m.ctx.DummyDataCreated { - m.collectionsView.SetDummyDataNotification(true) - } + a.height = msg.Height + a.width = msg.Width + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return a, tea.Quit } - m.collectionsView.SetSelectedIndex(m.selectedIndex) - return m, m.collectionsView.Init() - case views.EditCollectionMsg: - m.mode = EditCollectionViewMode - m.editCollectionView = views.NewEditCollectionView(m.ctx.Collections, msg.Collection) - return m, nil - case views.CollectionDeletedMsg: - return m, m.collectionsView.Init() - case views.CollectionDeleteErrorMsg: - return m, nil - case views.CollectionCreatedMsg: - m.addCollectionView.ClearForm() - m.mode = CollectionsViewMode - m.selectedIndex = 0 - m.collectionsView.SetSelectedIndex(m.selectedIndex) - return m, m.collectionsView.Init() - } - - switch m.mode { - case CollectionsViewMode: - m.collectionsView, cmd = m.collectionsView.Update(msg) - case AddCollectionViewMode: - m.addCollectionView, cmd = m.addCollectionView.Update(msg) - case EditCollectionViewMode: - m.editCollectionView, cmd = m.editCollectionView.Update(msg) - case SelectedCollectionViewMode: - m.selectedCollectionView, cmd = m.selectedCollectionView.Update(msg) } + return a, nil +} - return m, cmd +func (a AppModel) View() string { + return "Hello World" } -func (m Model) View() string { - switch m.mode { - case CollectionsViewMode: - return m.collectionsView.View() - case AddCollectionViewMode: - return m.addCollectionView.View() - case EditCollectionViewMode: - return m.editCollectionView.View() - case SelectedCollectionViewMode: - return m.selectedCollectionView.View() - default: - return m.collectionsView.View() - } +func NewAppModel() AppModel { + return AppModel{} } diff --git a/internal/tui/components/collection_item.go b/internal/tui/components/collection_item.go deleted file mode 100644 index d476c2b..0000000 --- a/internal/tui/components/collection_item.go +++ /dev/null @@ -1,36 +0,0 @@ -package components - -import ( - "fmt" - "strconv" - - "github.com/maniac-en/req/internal/backend/collections" -) - -type CollectionItem struct { - collection collections.CollectionEntity -} - -func NewCollectionItem(collection collections.CollectionEntity) CollectionItem { - return CollectionItem{collection: collection} -} - -func (i CollectionItem) FilterValue() string { - return i.collection.Name -} - -func (i CollectionItem) GetID() string { - return strconv.FormatInt(i.collection.ID, 10) -} - -func (i CollectionItem) GetTitle() string { - return i.collection.Name -} - -func (i CollectionItem) GetDescription() string { - return fmt.Sprintf("ID: %d", i.collection.ID) -} - -func (i CollectionItem) GetCollection() collections.CollectionEntity { - return i.collection -} diff --git a/internal/tui/components/endpoint_item.go b/internal/tui/components/endpoint_item.go deleted file mode 100644 index 236d46a..0000000 --- a/internal/tui/components/endpoint_item.go +++ /dev/null @@ -1,41 +0,0 @@ -package components - -import ( - "fmt" - - "github.com/maniac-en/req/internal/backend/endpoints" -) - -type EndpointItem struct { - endpoint endpoints.EndpointEntity -} - -func NewEndpointItem(endpoint endpoints.EndpointEntity) EndpointItem { - return EndpointItem{ - endpoint: endpoint, - } -} - -func (i EndpointItem) FilterValue() string { - return i.endpoint.Name -} - -func (i EndpointItem) GetID() string { - return fmt.Sprintf("%d", i.endpoint.ID) -} - -func (i EndpointItem) GetTitle() string { - return fmt.Sprintf("%s %s", i.endpoint.Method, i.endpoint.Name) -} - -func (i EndpointItem) GetDescription() string { - return i.endpoint.Url -} - -func (i EndpointItem) Title() string { - return i.GetTitle() -} - -func (i EndpointItem) Description() string { - return i.GetDescription() -} diff --git a/internal/tui/components/form.go b/internal/tui/components/form.go deleted file mode 100644 index 80b3f45..0000000 --- a/internal/tui/components/form.go +++ /dev/null @@ -1,144 +0,0 @@ -package components - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Form struct { - inputs []TextInput - focusIndex int - width int - height int - title string - submitText string - cancelText string -} - -func NewForm(title string, inputs []TextInput) Form { - if len(inputs) > 0 { - inputs[0].Focus() - } - - return Form{ - inputs: inputs, - focusIndex: 0, - title: title, - submitText: "Submit", - cancelText: "Cancel", - } -} - -func (f *Form) SetSize(width, height int) { - f.width = width - f.height = height - - for i := range f.inputs { - f.inputs[i].SetWidth(width - 4) - } -} - -func (f *Form) SetSubmitText(text string) { - f.submitText = text -} - -func (f *Form) SetCancelText(text string) { - f.cancelText = text -} - -func (f Form) GetInput(index int) *TextInput { - if index >= 0 && index < len(f.inputs) { - return &f.inputs[index] - } - return nil -} - -func (f Form) GetValues() []string { - values := make([]string, len(f.inputs)) - for i, input := range f.inputs { - values[i] = input.Value() - } - return values -} - -func (f *Form) Clear() { - for i := range f.inputs { - f.inputs[i].Clear() - } -} - -func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab", "down": - f.nextInput() - case "shift+tab", "up": - f.prevInput() - } - } - - if f.focusIndex >= 0 && f.focusIndex < len(f.inputs) { - f.inputs[f.focusIndex], cmd = f.inputs[f.focusIndex].Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return f, tea.Batch(cmds...) -} - -func (f *Form) nextInput() { - if len(f.inputs) == 0 { - return - } - - f.inputs[f.focusIndex].Blur() - f.focusIndex = (f.focusIndex + 1) % len(f.inputs) - f.inputs[f.focusIndex].Focus() -} - -func (f *Form) prevInput() { - if len(f.inputs) == 0 { - return - } - - f.inputs[f.focusIndex].Blur() - f.focusIndex-- - if f.focusIndex < 0 { - f.focusIndex = len(f.inputs) - 1 - } - f.inputs[f.focusIndex].Focus() -} - -func (f Form) View() string { - var content []string - - for _, input := range f.inputs { - content = append(content, input.View()) - } - - content = append(content, "") - - buttonStyle := styles.ListItemStyle.Copy(). - Padding(0, 2). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - - buttons := lipgloss.JoinHorizontal( - lipgloss.Top, - buttonStyle.Render(f.submitText+" (enter)"), - " ", - buttonStyle.Copy(). - Background(styles.TextSecondary). - Render(f.cancelText+" (esc)"), - ) - content = append(content, buttons) - - return lipgloss.JoinVertical(lipgloss.Left, content...) -} diff --git a/internal/tui/components/keyvalue_editor.go b/internal/tui/components/keyvalue_editor.go deleted file mode 100644 index af5bfeb..0000000 --- a/internal/tui/components/keyvalue_editor.go +++ /dev/null @@ -1,260 +0,0 @@ -package components - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type KeyValuePair struct { - Key string - Value string - Enabled bool -} - -type KeyValueEditor struct { - label string - pairs []KeyValuePair - width int - height int - focused bool - focusIndex int // Which pair is focused - fieldIndex int // 0=key, 1=value, 2=enabled -} - -func NewKeyValueEditor(label string) KeyValueEditor { - return KeyValueEditor{ - label: label, - pairs: []KeyValuePair{{"", "", true}}, // Start with one empty pair - width: 50, - height: 6, - focused: false, - focusIndex: 0, - fieldIndex: 0, - } -} - -func (kv *KeyValueEditor) SetSize(width, height int) { - kv.width = width - kv.height = height -} - -func (kv *KeyValueEditor) Focus() { - kv.focused = true -} - -func (kv *KeyValueEditor) Blur() { - kv.focused = false -} - -func (kv KeyValueEditor) Focused() bool { - return kv.focused -} - -func (kv *KeyValueEditor) SetPairs(pairs []KeyValuePair) { - if len(pairs) == 0 { - kv.pairs = []KeyValuePair{{"", "", true}} - } else { - kv.pairs = pairs - } - // Ensure focus is within bounds - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = len(kv.pairs) - 1 - } -} - -func (kv KeyValueEditor) GetPairs() []KeyValuePair { - return kv.pairs -} - -func (kv KeyValueEditor) GetEnabledPairsAsMap() map[string]string { - result := make(map[string]string) - for _, pair := range kv.pairs { - if pair.Enabled && pair.Key != "" { - result[pair.Key] = pair.Value - } - } - return result -} - -func (kv KeyValueEditor) Update(msg tea.Msg) (KeyValueEditor, tea.Cmd) { - if !kv.focused { - return kv, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab": - // Move to next field - kv.fieldIndex++ - if kv.fieldIndex > 2 { // key, value, enabled - kv.fieldIndex = 0 - kv.focusIndex++ - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = 0 - } - } - case "shift+tab": - // Move to previous field - kv.fieldIndex-- - if kv.fieldIndex < 0 { - kv.fieldIndex = 2 - kv.focusIndex-- - if kv.focusIndex < 0 { - kv.focusIndex = len(kv.pairs) - 1 - } - } - case "up": - if kv.focusIndex > 0 { - kv.focusIndex-- - } - case "down": - if kv.focusIndex < len(kv.pairs)-1 { - kv.focusIndex++ - } - case "ctrl+n": - // Add new pair - kv.pairs = append(kv.pairs, KeyValuePair{"", "", true}) - case "ctrl+d": - // Delete current pair (but keep at least one) - if len(kv.pairs) > 1 { - kv.pairs = append(kv.pairs[:kv.focusIndex], kv.pairs[kv.focusIndex+1:]...) - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = len(kv.pairs) - 1 - } - } - case " ": - // Toggle enabled state when on enabled field - if kv.fieldIndex == 2 { - kv.pairs[kv.focusIndex].Enabled = !kv.pairs[kv.focusIndex].Enabled - } - case "backspace": - // Delete character from current field - if kv.fieldIndex == 0 && len(kv.pairs[kv.focusIndex].Key) > 0 { - kv.pairs[kv.focusIndex].Key = kv.pairs[kv.focusIndex].Key[:len(kv.pairs[kv.focusIndex].Key)-1] - } else if kv.fieldIndex == 1 && len(kv.pairs[kv.focusIndex].Value) > 0 { - kv.pairs[kv.focusIndex].Value = kv.pairs[kv.focusIndex].Value[:len(kv.pairs[kv.focusIndex].Value)-1] - } - default: - // Add printable characters - if len(msg.String()) == 1 && msg.String() >= " " { - char := msg.String() - if kv.fieldIndex == 0 { - kv.pairs[kv.focusIndex].Key += char - } else if kv.fieldIndex == 1 { - kv.pairs[kv.focusIndex].Value += char - } - } - } - } - - return kv, nil -} - -func (kv KeyValueEditor) View() string { - // Calculate container dimensions (use full width like textarea) - containerWidth := kv.width - 4 // Just account for padding - if containerWidth < 30 { - containerWidth = 30 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(kv.height). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(1, 1) - - if kv.focused { - container = container.BorderForeground(styles.Primary) - } - - // Build content - var lines []string - visibleHeight := kv.height - 2 // Account for border - - // Header - better column proportions - headerStyle := styles.ListItemStyle.Copy().Bold(true) - availableWidth := containerWidth - 8 // Account for padding and separators - keyWidth := availableWidth * 40 / 100 // 40% for key - valueWidth := availableWidth * 50 / 100 // 50% for value - enabledWidth := availableWidth * 10 / 100 // 10% for enabled - - header := lipgloss.JoinHorizontal( - lipgloss.Top, - headerStyle.Copy().Width(keyWidth).Render("Key"), - " ", - headerStyle.Copy().Width(valueWidth).Render("Value"), - " ", - headerStyle.Copy().Width(enabledWidth).Align(lipgloss.Center).Render("On"), - ) - lines = append(lines, header) - - // Show pairs (limit to visible height) - maxPairs := visibleHeight - 2 // Reserve space for header and instructions - if maxPairs < 1 { - maxPairs = 1 - } - - for i := 0; i < maxPairs && i < len(kv.pairs); i++ { - pair := kv.pairs[i] - - // Style fields based on focus - keyStyle := styles.ListItemStyle.Copy().Width(keyWidth) - valueStyle := styles.ListItemStyle.Copy().Width(valueWidth) - enabledStyle := styles.ListItemStyle.Copy().Width(enabledWidth).Align(lipgloss.Center) - - if kv.focused && i == kv.focusIndex { - if kv.fieldIndex == 0 { - keyStyle = keyStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } else if kv.fieldIndex == 1 { - valueStyle = valueStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } else if kv.fieldIndex == 2 { - enabledStyle = enabledStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } - } - - // Truncate long text - keyText := pair.Key - if len(keyText) > keyWidth-2 { - keyText = keyText[:keyWidth-2] - } - valueText := pair.Value - if len(valueText) > valueWidth-2 { - valueText = valueText[:valueWidth-2] - } - - checkbox := "☐" - if pair.Enabled { - checkbox = "☑" - } - - row := lipgloss.JoinHorizontal( - lipgloss.Top, - keyStyle.Render(keyText), - " ", - valueStyle.Render(valueText), - " ", - enabledStyle.Render(checkbox), - ) - lines = append(lines, row) - } - - // Add instructions at bottom - if len(lines) < visibleHeight-1 { - instructions := "tab: next field • ↑↓: navigate rows • space: toggle" - instrStyle := styles.ListItemStyle.Copy().Foreground(styles.TextMuted) - lines = append(lines, "", instrStyle.Render(instructions)) - } - - // Fill remaining space - for len(lines) < visibleHeight { - lines = append(lines, "") - } - - content := lipgloss.JoinVertical(lipgloss.Left, lines...) - containerView := container.Render(content) - - return containerView -} diff --git a/internal/tui/components/layout.go b/internal/tui/components/layout.go deleted file mode 100644 index a191e07..0000000 --- a/internal/tui/components/layout.go +++ /dev/null @@ -1,146 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Layout struct { - width int - height int -} - -func NewLayout() Layout { - return Layout{} -} - -func (l *Layout) SetSize(width, height int) { - l.width = width - l.height = height -} - -func (l Layout) Header(title string) string { - return styles.HeaderStyle. - Width(l.width). - Render(title) -} - -func (l Layout) Footer(instructions string) string { - return styles.FooterStyle. - Width(l.width). - Render(instructions) -} - -func (l Layout) Content(content string, headerHeight, footerHeight int) string { - contentHeight := l.height - headerHeight - footerHeight - if contentHeight < 0 { - contentHeight = 0 - } - - return styles.ContentStyle. - Width(l.width). - Height(contentHeight). - Render(content) -} - -func (l Layout) FullView(title, content, instructions string) string { - if l.width < 20 || l.height < 10 { - return content - } - - // Calculate window dimensions (85% of terminal width, 80% height) - windowWidth := int(float64(l.width) * 0.85) - windowHeight := int(float64(l.height) * 0.8) - - // Ensure minimum dimensions - if windowWidth < 50 { - windowWidth = 50 - } - if windowHeight < 15 { - windowHeight = 15 - } - - // Calculate inner content dimensions (accounting for border) - innerWidth := windowWidth - 4 // 2 chars for border + padding - innerHeight := windowHeight - 4 - - // Create header and content with simplified, consistent styling - header := lipgloss.NewStyle(). - Width(innerWidth). - Padding(1, 2). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true). - Align(lipgloss.Center). - Render(title) - - headerHeight := lipgloss.Height(header) - contentHeight := innerHeight - headerHeight - - if contentHeight < 1 { - contentHeight = 1 - } - - contentArea := lipgloss.NewStyle(). - Width(innerWidth). - Height(contentHeight). - Padding(1, 2). - Render(content) - - // Join header and content vertically (no footer) - windowContent := lipgloss.JoinVertical( - lipgloss.Left, - header, - contentArea, - ) - - // Create bordered window - borderedWindow := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("15")). // White border - Width(windowWidth). - Height(windowHeight). - Render(windowContent) - - // Create elegant app branding banner at top - brandingText := "Req - Test APIs with Terminal Velocity" - appBranding := lipgloss.NewStyle(). - Width(l.width). - Align(lipgloss.Center). - Foreground(lipgloss.Color("230")). // Soft cream - // Background(lipgloss.Color("237")). // Dark gray background - Bold(true). - Padding(1, 4). - Margin(1, 0). - Render(brandingText) - - // Create footer outside the window - footer := lipgloss.NewStyle(). - Width(l.width). - Padding(0, 2). - Foreground(styles.TextSecondary). - Align(lipgloss.Center). - Render(instructions) - - // Calculate vertical position accounting for branding and footer - brandingHeight := lipgloss.Height(appBranding) - footerHeight := lipgloss.Height(footer) - windowPlacementHeight := l.height - brandingHeight - footerHeight - 4 // Extra padding - - centeredWindow := lipgloss.Place( - l.width, windowPlacementHeight, - lipgloss.Center, lipgloss.Center, - borderedWindow, - ) - - // Combine branding, centered window, and footer with proper spacing - return lipgloss.JoinVertical( - lipgloss.Left, - "", // Top padding - appBranding, - "", // Extra spacing line - centeredWindow, - "", // Reduced spacing before footer - footer, - ) -} diff --git a/internal/tui/components/paginated_list.go b/internal/tui/components/paginated_list.go deleted file mode 100644 index 10710f6..0000000 --- a/internal/tui/components/paginated_list.go +++ /dev/null @@ -1,119 +0,0 @@ -package components - -import ( - "fmt" - "io" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type ListItem interface { - list.Item - GetID() string - GetTitle() string - GetDescription() string -} - -type PaginatedList struct { - list list.Model - width int - height int -} - -func NewPaginatedList(items []ListItem, title string) PaginatedList { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = item - } - - const defaultWidth = 120 // Wide enough to avoid title truncation - const defaultHeight = 20 - - l := list.New(listItems, paginatedItemDelegate{}, defaultWidth, defaultHeight) - l.Title = title - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.SetShowHelp(false) - l.SetShowPagination(false) - l.SetShowTitle(true) - - l.Styles.StatusBar = lipgloss.NewStyle() - l.Styles.PaginationStyle = lipgloss.NewStyle() - l.Styles.HelpStyle = lipgloss.NewStyle() - l.Styles.FilterPrompt = lipgloss.NewStyle() - l.Styles.FilterCursor = lipgloss.NewStyle() - l.Styles.Title = styles.TitleStyle.Copy().MarginBottom(0).PaddingBottom(0) - - return PaginatedList{ - list: l, - } -} - -func (pl *PaginatedList) SetSize(width, height int) { - pl.width = width - pl.height = height - - // Safety check to prevent nil pointer dereference - if width > 0 && height > 0 { - pl.list.SetWidth(width) - pl.list.SetHeight(height) - } -} - -func (pl PaginatedList) Init() tea.Cmd { - return nil -} - -func (pl PaginatedList) Update(msg tea.Msg) (PaginatedList, tea.Cmd) { - newListModel, cmd := pl.list.Update(msg) - pl.list = newListModel - return pl, cmd -} - -func (pl PaginatedList) View() string { - return pl.list.View() -} - -func (pl PaginatedList) SelectedItem() ListItem { - if selectedItem := pl.list.SelectedItem(); selectedItem != nil { - if listItem, ok := selectedItem.(ListItem); ok { - return listItem - } - } - return nil -} - -func (pl PaginatedList) SelectedIndex() int { - return pl.list.Index() -} - -func (pl *PaginatedList) SetIndex(i int) { - pl.list.Select(i) -} - -func (pl PaginatedList) IsFiltering() bool { - return pl.list.FilterState() == list.Filtering -} - -type paginatedItemDelegate struct{} - -func (d paginatedItemDelegate) Height() int { return 1 } -func (d paginatedItemDelegate) Spacing() int { return 0 } -func (d paginatedItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d paginatedItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - if i, ok := item.(ListItem); ok { - str := i.GetTitle() - - fn := styles.ListItemStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - return styles.SelectedListItemStyle.Render("> " + s[0]) - } - } - - fmt.Fprint(w, fn(str)) - } -} diff --git a/internal/tui/components/text_input.go b/internal/tui/components/text_input.go deleted file mode 100644 index 4049b1f..0000000 --- a/internal/tui/components/text_input.go +++ /dev/null @@ -1,107 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type TextInput struct { - textInput textinput.Model - label string - width int -} - -func NewTextInput(label, placeholder string) TextInput { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Focus() - ti.CharLimit = 5000 // Allow long content like JSON - ti.Width = 50 - - return TextInput{ - textInput: ti, - label: label, - width: 50, - } -} - -func (t *TextInput) SetValue(value string) { - t.textInput.SetValue(value) -} - -func (t TextInput) Value() string { - return t.textInput.Value() -} - -func (t *TextInput) SetWidth(width int) { - t.width = width - // Account for label, colon, spacing, and border padding - containerWidth := width - 12 - 1 - 2 // 12 for label, 1 for colon, 2 for spacing - if containerWidth < 15 { - containerWidth = 15 - } - - // The actual input width inside the container (subtract border and padding) - inputWidth := containerWidth - 4 // 2 for border, 2 for padding - if inputWidth < 10 { - inputWidth = 10 - } - - // Ensure the underlying textinput respects the width - t.textInput.Width = inputWidth -} - -func (t *TextInput) Focus() { - t.textInput.Focus() -} - -func (t *TextInput) Blur() { - t.textInput.Blur() -} - -func (t *TextInput) Clear() { - t.textInput.SetValue("") -} - -func (t TextInput) Focused() bool { - return t.textInput.Focused() -} - -func (t TextInput) Update(msg tea.Msg) (TextInput, tea.Cmd) { - var cmd tea.Cmd - t.textInput, cmd = t.textInput.Update(msg) - return t, cmd -} - -func (t TextInput) View() string { - labelStyle := styles.TitleStyle.Copy(). - Width(12). - MarginTop(1). - Align(lipgloss.Right) - - // Create a fixed-width container for the input to prevent overflow - containerWidth := t.width - 12 - 1 - 2 // Account for label, colon, spacing - if containerWidth < 15 { - containerWidth = 15 - } - - inputContainer := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 1) - - if t.textInput.Focused() { - inputContainer = inputContainer.BorderForeground(styles.Primary) - } - - return lipgloss.JoinHorizontal( - lipgloss.Top, - labelStyle.Render(t.label+":"), - " ", - inputContainer.Render(t.textInput.View()), - ) -} diff --git a/internal/tui/components/textarea.go b/internal/tui/components/textarea.go deleted file mode 100644 index 01ba878..0000000 --- a/internal/tui/components/textarea.go +++ /dev/null @@ -1,311 +0,0 @@ -package components - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Textarea struct { - label string - content string - width int - height int - focused bool - cursor int - lines []string - cursorRow int - cursorCol int - scrollOffset int -} - -func NewTextarea(label, placeholder string) Textarea { - return Textarea{ - label: label, - content: "", - width: 50, - height: 6, - focused: false, - cursor: 0, - lines: []string{""}, - cursorRow: 0, - cursorCol: 0, - scrollOffset: 0, - } -} - -func (t *Textarea) SetValue(value string) { - t.content = value - rawLines := strings.Split(value, "\n") - if len(rawLines) == 0 { - rawLines = []string{""} - } - - // Wrap long lines to fit within the textarea width - t.lines = []string{} - contentWidth := t.getContentWidth() - - for _, line := range rawLines { - if len(line) <= contentWidth { - t.lines = append(t.lines, line) - } else { - // Wrap long lines - wrapped := t.wrapLine(line, contentWidth) - t.lines = append(t.lines, wrapped...) - } - } - - if len(t.lines) == 0 { - t.lines = []string{""} - } - - // Set cursor to end - t.cursorRow = len(t.lines) - 1 - t.cursorCol = len(t.lines[t.cursorRow]) -} - -func (t Textarea) Value() string { - return strings.Join(t.lines, "\n") -} - -func (t *Textarea) SetSize(width, height int) { - t.width = width - t.height = height -} - -func (t *Textarea) Focus() { - t.focused = true -} - -func (t *Textarea) Blur() { - t.focused = false -} - -func (t Textarea) Focused() bool { - return t.focused -} - -func (t *Textarea) moveCursor(row, col int) { - // Ensure row is in bounds - if row < 0 { - row = 0 - } - if row >= len(t.lines) { - row = len(t.lines) - 1 - } - - // Ensure col is in bounds for the row - if col < 0 { - col = 0 - } - if col > len(t.lines[row]) { - col = len(t.lines[row]) - } - - t.cursorRow = row - t.cursorCol = col -} - -func (t Textarea) Update(msg tea.Msg) (Textarea, tea.Cmd) { - if !t.focused { - return t, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "enter": - // Insert new line - currentLine := t.lines[t.cursorRow] - beforeCursor := currentLine[:t.cursorCol] - afterCursor := currentLine[t.cursorCol:] - - t.lines[t.cursorRow] = beforeCursor - newLines := make([]string, len(t.lines)+1) - copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1]) - newLines[t.cursorRow+1] = afterCursor - copy(newLines[t.cursorRow+2:], t.lines[t.cursorRow+1:]) - t.lines = newLines - - t.cursorRow++ - t.cursorCol = 0 - - case "tab": - // Insert 2 spaces for indentation - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + " " + currentLine[t.cursorCol:] - t.cursorCol += 2 - - case "backspace": - if t.cursorCol > 0 { - // Remove character - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol-1] + currentLine[t.cursorCol:] - t.cursorCol-- - } else if t.cursorRow > 0 { - // Join with previous line - prevLine := t.lines[t.cursorRow-1] - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow-1] = prevLine + currentLine - - newLines := make([]string, len(t.lines)-1) - copy(newLines[:t.cursorRow], t.lines[:t.cursorRow]) - copy(newLines[t.cursorRow:], t.lines[t.cursorRow+1:]) - t.lines = newLines - - t.cursorRow-- - t.cursorCol = len(prevLine) - } - - case "delete": - if t.cursorCol < len(t.lines[t.cursorRow]) { - // Remove character - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + currentLine[t.cursorCol+1:] - } else if t.cursorRow < len(t.lines)-1 { - // Join with next line - currentLine := t.lines[t.cursorRow] - nextLine := t.lines[t.cursorRow+1] - t.lines[t.cursorRow] = currentLine + nextLine - - newLines := make([]string, len(t.lines)-1) - copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1]) - copy(newLines[t.cursorRow+1:], t.lines[t.cursorRow+2:]) - t.lines = newLines - } - - case "up": - t.moveCursor(t.cursorRow-1, t.cursorCol) - case "down": - t.moveCursor(t.cursorRow+1, t.cursorCol) - case "left": - if t.cursorCol > 0 { - t.cursorCol-- - } else if t.cursorRow > 0 { - t.cursorRow-- - t.cursorCol = len(t.lines[t.cursorRow]) - } - case "right": - if t.cursorCol < len(t.lines[t.cursorRow]) { - t.cursorCol++ - } else if t.cursorRow < len(t.lines)-1 { - t.cursorRow++ - t.cursorCol = 0 - } - case "home": - t.cursorCol = 0 - case "end": - t.cursorCol = len(t.lines[t.cursorRow]) - - default: - // Insert printable characters - if len(msg.String()) == 1 && msg.String() >= " " { - char := msg.String() - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + char + currentLine[t.cursorCol:] - t.cursorCol++ - } - } - } - - return t, nil -} - -func (t Textarea) View() string { - // Use full width since we don't need label space - containerWidth := t.width - 4 // Just account for padding - if containerWidth < 20 { - containerWidth = 20 - } - - // Create the textarea container - containerHeight := t.height - if containerHeight < 3 { - containerHeight = 3 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(containerHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 1) - - if t.focused { - container = container.BorderForeground(styles.Primary) - } - - // Prepare visible lines with cursor - visibleLines := make([]string, containerHeight-2) // Account for border - for i := 0; i < len(visibleLines); i++ { - lineIndex := i // No scrolling for now - if lineIndex < len(t.lines) { - line := t.lines[lineIndex] - - // Add cursor if this is the cursor row and textarea is focused - if t.focused && lineIndex == t.cursorRow { - if t.cursorCol <= len(line) { - line = line[:t.cursorCol] + "│" + line[t.cursorCol:] - } - } - - // Lines should already be wrapped, no need to truncate - - visibleLines[i] = line - } else { - visibleLines[i] = "" - } - } - - content := strings.Join(visibleLines, "\n") - textareaView := container.Render(content) - - return textareaView -} - -func (t Textarea) getContentWidth() int { - // Calculate content width (no label needed) - containerWidth := t.width - 4 // Just account for padding - if containerWidth < 20 { - containerWidth = 20 - } - contentWidth := containerWidth - 4 // border + padding - if contentWidth < 10 { - contentWidth = 10 - } - return contentWidth -} - -func (t Textarea) wrapLine(line string, maxWidth int) []string { - if len(line) <= maxWidth { - return []string{line} - } - - var wrapped []string - for len(line) > maxWidth { - // Find the best place to break (prefer spaces) - breakPoint := maxWidth - for i := maxWidth - 1; i >= maxWidth-20 && i >= 0; i-- { - if line[i] == ' ' { - breakPoint = i - break - } - } - - wrapped = append(wrapped, line[:breakPoint]) - line = line[breakPoint:] - - // Skip leading space on continuation lines - if len(line) > 0 && line[0] == ' ' { - line = line[1:] - } - } - - if len(line) > 0 { - wrapped = append(wrapped, line) - } - - return wrapped -} diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go deleted file mode 100644 index 958f23a..0000000 --- a/internal/tui/styles/colors.go +++ /dev/null @@ -1,21 +0,0 @@ -package styles - -import "github.com/charmbracelet/lipgloss" - -var ( - // Primary colors - Warm & Earthy - Primary = lipgloss.Color("95") // Muted reddish-brown (e.g., rust) - Secondary = lipgloss.Color("101") // Soft olive green - Success = lipgloss.Color("107") // Earthy sage green - Warning = lipgloss.Color("172") // Warm goldenrod/ochre - Error = lipgloss.Color("160") // Deep muted red - - // Text colors - TextPrimary = lipgloss.Color("254") // Off-white/cream - TextSecondary = lipgloss.Color("246") // Medium warm gray - TextMuted = lipgloss.Color("241") // Darker warm gray - - // Background colors - BackgroundPrimary = lipgloss.Color("235") // Very dark brown-gray - BackgroundSecondary = lipgloss.Color("238") // Dark brown-gray -) diff --git a/internal/tui/styles/layout.go b/internal/tui/styles/layout.go deleted file mode 100644 index 8f933ba..0000000 --- a/internal/tui/styles/layout.go +++ /dev/null @@ -1,41 +0,0 @@ -package styles - -import "github.com/charmbracelet/lipgloss" - -var ( - HeaderStyle = lipgloss.NewStyle(). - Padding(1, 2). - Background(Primary). - Foreground(TextPrimary). - Bold(true). - Align(lipgloss.Center) - - FooterStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(TextSecondary). - Align(lipgloss.Center) - - ContentStyle = lipgloss.NewStyle(). - Padding(1, 2) - - ListItemStyle = lipgloss.NewStyle(). - PaddingLeft(4) - - SelectedListItemStyle = lipgloss.NewStyle(). - PaddingLeft(2). - Foreground(Secondary) - - TitleStyle = lipgloss.NewStyle(). - MarginLeft(2). - MarginBottom(1). - Foreground(Primary). - Bold(true) - - SidebarStyle = lipgloss.NewStyle(). - BorderRight(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(Secondary) - - MainContentStyle = lipgloss.NewStyle(). - PaddingLeft(2) -) diff --git a/internal/tui/views/add_collection.go b/internal/tui/views/add_collection.go deleted file mode 100644 index 8d33600..0000000 --- a/internal/tui/views/add_collection.go +++ /dev/null @@ -1,140 +0,0 @@ -package views - -import ( - "context" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" -) - -type AddCollectionView struct { - layout components.Layout - form components.Form - collectionsManager *collections.CollectionsManager - width int - height int - submitting bool -} - -func NewAddCollectionView(collectionsManager *collections.CollectionsManager) AddCollectionView { - inputs := []components.TextInput{ - components.NewTextInput("Name", "Enter collection name"), - } - - form := components.NewForm("Add Collection", inputs) - form.SetSubmitText("Create") - - return AddCollectionView{ - layout: components.NewLayout(), - form: form, - collectionsManager: collectionsManager, - } -} - -func (v AddCollectionView) Init() tea.Cmd { - return nil -} - -func (v AddCollectionView) Update(msg tea.Msg) (AddCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-50, v.height-8) - - case tea.KeyMsg: - if v.submitting { - return v, nil - } - - switch msg.String() { - case "enter": - return v, func() tea.Msg { return v.submitForm() } - case "esc": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - } - - case CollectionCreateErrorMsg: - v.submitting = false - } - - v.form, cmd = v.form.Update(msg) - return v, cmd -} - -func (v *AddCollectionView) submitForm() tea.Msg { - v.submitting = true - values := v.form.GetValues() - - if len(values) == 0 || values[0] == "" { - return CollectionCreateErrorMsg{err: crud.ErrInvalidInput} - } - - return v.createCollection(values[0]) -} - -func (v *AddCollectionView) createCollection(name string) tea.Msg { - collection, err := v.collectionsManager.Create(context.Background(), name) - if err != nil { - return CollectionCreateErrorMsg{err: err} - } - return CollectionCreatedMsg{collection: collection} -} - -func (v *AddCollectionView) ClearForm() { - v.form.Clear() -} - -func (v AddCollectionView) View() string { - if v.submitting { - return v.layout.FullView( - "Add Collection", - "Creating collection...", - "Please wait", - ) - } - - content := v.form.View() - instructions := "tab/↑↓: navigate • enter: create • esc: cancel" - - return v.layout.FullView( - "Add Collection", - content, - instructions, - ) -} - -type CollectionCreatedMsg struct { - collection collections.CollectionEntity -} - -type CollectionCreateErrorMsg struct { - err error -} - -type CollectionUpdatedMsg struct { - collection collections.CollectionEntity -} - -type CollectionUpdateErrorMsg struct { - err error -} - -type CollectionDeletedMsg struct { - ID int64 -} - -type CollectionDeleteErrorMsg struct { - Err error -} - -type BackToCollectionsMsg struct{} - -type EditCollectionMsg struct { - Collection collections.CollectionEntity -} diff --git a/internal/tui/views/collections.go b/internal/tui/views/collections.go deleted file mode 100644 index c5a8576..0000000 --- a/internal/tui/views/collections.go +++ /dev/null @@ -1,235 +0,0 @@ -package views - -import ( - "context" - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type CollectionsView struct { - layout components.Layout - list components.PaginatedList - collectionsManager *collections.CollectionsManager - width int - height int - initialized bool - selectedIndex int - showDummyDataNotif bool - - currentPage int - pageSize int - pagination crud.PaginationMetadata -} - -func NewCollectionsView(collectionsManager *collections.CollectionsManager) CollectionsView { - return CollectionsView{ - layout: components.NewLayout(), - collectionsManager: collectionsManager, - } -} - -func NewCollectionsViewWithSize(collectionsManager *collections.CollectionsManager, width, height int) CollectionsView { - layout := components.NewLayout() - layout.SetSize(width, height) - return CollectionsView{ - layout: layout, - collectionsManager: collectionsManager, - width: width, - height: height, - } -} - -func (v *CollectionsView) SetDummyDataNotification(show bool) { - v.showDummyDataNotif = show -} - -func (v CollectionsView) Init() tea.Cmd { - return v.loadCollections -} - -func (v *CollectionsView) loadCollections() tea.Msg { - pageToLoad := v.currentPage - if pageToLoad == 0 { - pageToLoad = 1 - } - pageSizeToLoad := v.pageSize - if pageSizeToLoad == 0 { - pageSizeToLoad = 20 - } - - if v.initialized { - v.selectedIndex = v.list.SelectedIndex() - } else { - v.selectedIndex = 0 - } - - return v.loadCollectionsPage(pageToLoad, pageSizeToLoad) -} - -func (v *CollectionsView) loadCollectionsPage(page, pageSize int) tea.Msg { - offset := (page - 1) * pageSize - result, err := v.collectionsManager.ListPaginated(context.Background(), pageSize, offset) - if err != nil { - return collectionsLoadError{err: err} - } - return collectionsLoaded{ - collections: result.Collections, - pagination: result.PaginationMetadata, - currentPage: page, - pageSize: pageSize, - } -} - -type collectionsLoaded struct { - collections []collections.CollectionEntity - pagination crud.PaginationMetadata - currentPage int - pageSize int -} - -type collectionsLoadError struct { - err error -} - -func (v CollectionsView) Update(msg tea.Msg) (CollectionsView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - - case collectionsLoaded: - items := make([]components.ListItem, len(msg.collections)) - for i, collection := range msg.collections { - items[i] = components.NewCollectionItem(collection) - } - - v.currentPage = msg.currentPage - v.pageSize = msg.pageSize - v.pagination = msg.pagination - - title := fmt.Sprintf("Page %d / %d", v.currentPage, v.pagination.TotalPages) - v.list = components.NewPaginatedList(items, title) - v.list.SetIndex(v.selectedIndex) - - v.initialized = true - - case collectionsLoadError: - v.initialized = true - - case tea.KeyMsg: - if !v.initialized { - break - } - - // Clear dummy data notification on any keypress - if v.showDummyDataNotif { - v.showDummyDataNotif = false - } - - if !v.list.IsFiltering() { - switch msg.String() { - case "n", "right": - if v.currentPage < v.pagination.TotalPages { - v.selectedIndex = 0 - return v, func() tea.Msg { - return v.loadCollectionsPage(v.currentPage+1, v.pageSize) - } - } - return v, nil - case "p", "left": - if v.currentPage > 1 { - v.selectedIndex = 0 - return v, func() tea.Msg { - return v.loadCollectionsPage(v.currentPage-1, v.pageSize) - } - } - return v, nil - } - } - - v.list, cmd = v.list.Update(msg) - - default: - if v.initialized { - v.list, cmd = v.list.Update(msg) - } - } - - return v, cmd -} - -func (v CollectionsView) IsFiltering() bool { - return v.initialized && v.list.IsFiltering() -} - -func (v CollectionsView) IsInitialized() bool { - return v.initialized -} - -func (v *CollectionsView) SetSelectedIndex(index int) { - v.selectedIndex = index - if v.initialized { - v.list.SetIndex(index) - } -} - -func (v CollectionsView) GetSelectedItem() *collections.CollectionEntity { - if !v.initialized { - return nil - } - if selectedItem := v.list.SelectedItem(); selectedItem != nil { - if collectionItem, ok := selectedItem.(components.CollectionItem); ok { - collection := collectionItem.GetCollection() - return &collection - } - } - return nil -} - -func (v CollectionsView) GetSelectedIndex() int { - return v.list.SelectedIndex() -} - -func (v CollectionsView) View() string { - if !v.initialized { - return v.layout.FullView( - "Collections", - "Loading collections...", - "Please wait", - ) - } - - content := v.list.View() - - // Build instructions with pagination and filter info - instructions := "↑↓: navigate • /: filter • e: edit • x: delete • q: quit" - if !v.list.IsFiltering() { - instructions = "↑↓: navigate • a: add • /: filter • e: edit • x: delete • q: quit" - } - if v.pagination.TotalPages > 1 && !v.list.IsFiltering() { - instructions += " • p/n: prev/next page" - } - - // Show dummy data notification if needed - if v.showDummyDataNotif { - instructions = lipgloss.NewStyle(). - Foreground(styles.Success). - Bold(true). - Render("✓ Demo data created! 3 collections with sample API endpoints ready to explore") - } - - return v.layout.FullView( - "Collections", - content, - instructions, - ) -} diff --git a/internal/tui/views/edit_collection.go b/internal/tui/views/edit_collection.go deleted file mode 100644 index a6c7170..0000000 --- a/internal/tui/views/edit_collection.go +++ /dev/null @@ -1,113 +0,0 @@ -package views - -import ( - "context" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" -) - -type EditCollectionView struct { - layout components.Layout - form components.Form - collectionsManager *collections.CollectionsManager - collection collections.CollectionEntity - width int - height int - submitting bool -} - -func NewEditCollectionView(collectionsManager *collections.CollectionsManager, collection collections.CollectionEntity) EditCollectionView { - inputs := []components.TextInput{ - components.NewTextInput("Name", "Enter collection name"), - } - - inputs[0].SetValue(collection.Name) - - form := components.NewForm("Edit Collection", inputs) - form.SetSubmitText("Update") - - return EditCollectionView{ - layout: components.NewLayout(), - form: form, - collectionsManager: collectionsManager, - collection: collection, - } -} - -func (v EditCollectionView) Init() tea.Cmd { - return nil -} - -func (v EditCollectionView) Update(msg tea.Msg) (EditCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-50, v.height-8) - - case tea.KeyMsg: - if v.submitting { - return v, nil - } - - switch msg.String() { - case "enter": - return v, func() tea.Msg { return v.submitForm() } - case "esc": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - } - - case CollectionUpdatedMsg: - return v, func() tea.Msg { return BackToCollectionsMsg{} } - - case CollectionUpdateErrorMsg: - v.submitting = false - } - - v.form, cmd = v.form.Update(msg) - return v, cmd -} - -func (v *EditCollectionView) submitForm() tea.Msg { - v.submitting = true - values := v.form.GetValues() - - if len(values) == 0 || values[0] == "" { - return CollectionUpdateErrorMsg{err: crud.ErrInvalidInput} - } - - return v.updateCollection(values[0]) -} - -func (v *EditCollectionView) updateCollection(name string) tea.Msg { - updatedCollection, err := v.collectionsManager.Update(context.Background(), v.collection.ID, name) - if err != nil { - return CollectionUpdateErrorMsg{err: err} - } - return CollectionUpdatedMsg{collection: updatedCollection} -} - -func (v EditCollectionView) View() string { - if v.submitting { - return v.layout.FullView( - "Edit Collection", - "Updating collection...", - "Please wait", - ) - } - - content := v.form.View() - instructions := "tab/↑↓: navigate • enter: update • esc: cancel" - - return v.layout.FullView( - "Edit Collection", - content, - instructions, - ) -} diff --git a/internal/tui/views/endpoint_sidebar.go b/internal/tui/views/endpoint_sidebar.go deleted file mode 100644 index 42de45d..0000000 --- a/internal/tui/views/endpoint_sidebar.go +++ /dev/null @@ -1,179 +0,0 @@ -package views - -import ( - "context" - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/tui/components" -) - -type EndpointSidebarView struct { - list components.PaginatedList - endpointsManager *endpoints.EndpointsManager - collection collections.CollectionEntity - width int - height int - initialized bool - selectedIndex int - endpoints []endpoints.EndpointEntity - focused bool -} - -func NewEndpointSidebarView(endpointsManager *endpoints.EndpointsManager, collection collections.CollectionEntity) EndpointSidebarView { - return EndpointSidebarView{ - endpointsManager: endpointsManager, - collection: collection, - selectedIndex: 0, - focused: false, - } -} - -func (v *EndpointSidebarView) Focus() { - v.focused = true -} - -func (v *EndpointSidebarView) Blur() { - v.focused = false -} - -func (v EndpointSidebarView) Focused() bool { - return v.focused -} - -func (v EndpointSidebarView) Init() tea.Cmd { - return v.loadEndpoints -} - -func (v *EndpointSidebarView) loadEndpoints() tea.Msg { - result, err := v.endpointsManager.ListByCollection(context.Background(), v.collection.ID, 100, 0) - if err != nil { - return endpointsLoadError{err: err} - } - return endpointsLoaded{ - endpoints: result.Endpoints, - } -} - -func (v EndpointSidebarView) Update(msg tea.Msg) (EndpointSidebarView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - if v.initialized { - v.list.SetSize(v.width, v.height) - } - - case endpointsLoaded: - v.endpoints = msg.endpoints - items := make([]components.ListItem, len(msg.endpoints)) - for i, endpoint := range msg.endpoints { - items[i] = components.NewEndpointItem(endpoint) - } - - title := fmt.Sprintf("Endpoints (%d)", len(msg.endpoints)) - v.list = components.NewPaginatedList(items, title) - v.list.SetIndex(v.selectedIndex) - - if v.width > 0 && v.height > 0 { - v.list.SetSize(v.width, v.height) - } - v.initialized = true - - // Auto-select first endpoint if available - if len(msg.endpoints) > 0 { - return v, func() tea.Msg { - return EndpointSelectedMsg{Endpoint: msg.endpoints[0]} - } - } - - case endpointsLoadError: - v.initialized = true - - case tea.KeyMsg: - if v.initialized { - // Forward navigation keys to the list even if not explicitly focused - oldIndex := v.list.SelectedIndex() - v.list, cmd = v.list.Update(msg) - newIndex := v.list.SelectedIndex() - - // If the selected index changed, auto-select the new endpoint - if oldIndex != newIndex && newIndex >= 0 && newIndex < len(v.endpoints) { - return v, func() tea.Msg { - return EndpointSelectedMsg{Endpoint: v.endpoints[newIndex]} - } - } - } - } - - return v, cmd -} - -func (v EndpointSidebarView) GetSelectedEndpoint() *endpoints.EndpointEntity { - if !v.initialized || len(v.endpoints) == 0 { - return nil - } - - selectedIndex := v.list.SelectedIndex() - if selectedIndex >= 0 && selectedIndex < len(v.endpoints) { - return &v.endpoints[selectedIndex] - } - return nil -} - -func (v EndpointSidebarView) GetSelectedIndex() int { - if v.initialized { - return v.list.SelectedIndex() - } - return v.selectedIndex -} - -func (v *EndpointSidebarView) SetSelectedIndex(index int) { - v.selectedIndex = index - if v.initialized { - v.list.SetIndex(index) - } -} - -func (v EndpointSidebarView) View() string { - if !v.initialized { - title := "Endpoints" - content := "Loading endpoints..." - return v.formatEmptyState(title, content) - } - if len(v.endpoints) == 0 { - title := "Endpoints (0)" - content := "No endpoints found" - return v.formatEmptyState(title, content) - } - return v.list.View() -} - -func (v EndpointSidebarView) formatEmptyState(title, content string) string { - var lines []string - lines = append(lines, title) - lines = append(lines, "") - lines = append(lines, content) - - for len(lines) < v.height-2 { - lines = append(lines, "") - } - - result := "" - for _, line := range lines { - result += line + "\n" - } - return result -} - -type endpointsLoaded struct { - endpoints []endpoints.EndpointEntity -} - -type endpointsLoadError struct { - err error -} diff --git a/internal/tui/views/request_builder.go b/internal/tui/views/request_builder.go deleted file mode 100644 index 893a86c..0000000 --- a/internal/tui/views/request_builder.go +++ /dev/null @@ -1,325 +0,0 @@ -package views - -import ( - "encoding/json" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type RequestBuilderTab int - -const ( - RequestBodyTab RequestBuilderTab = iota - HeadersTab - QueryParamsTab -) - -type RequestBuilder struct { - endpoint *endpoints.EndpointEntity - method string - url string - requestBody string - activeTab RequestBuilderTab - bodyTextarea components.Textarea - headersEditor components.KeyValueEditor - queryEditor components.KeyValueEditor - width int - height int - focused bool - componentFocused bool // Whether we're actually editing a component -} - -func NewRequestBuilder() RequestBuilder { - bodyTextarea := components.NewTextarea("Body", "Enter request body (JSON, text, etc.)") - headersEditor := components.NewKeyValueEditor("Headers") - queryEditor := components.NewKeyValueEditor("Query Params") - - return RequestBuilder{ - method: "GET", - url: "", - requestBody: "", - activeTab: RequestBodyTab, - bodyTextarea: bodyTextarea, - headersEditor: headersEditor, - queryEditor: queryEditor, - focused: false, - componentFocused: false, - } -} - -func (rb *RequestBuilder) SetSize(width, height int) { - rb.width = width - rb.height = height - - // Set size for body textarea (use most of available width) - // Use about 90% of available width for better JSON editing - textareaWidth := int(float64(width) * 0.9) - if textareaWidth > 120 { - textareaWidth = 120 // Cap at reasonable max width - } - if textareaWidth < 60 { - textareaWidth = 60 // Ensure minimum usable width - } - - // Set height for textarea (leave space for method/URL, tabs) - textareaHeight := height - 8 // Account for method/URL row + tabs + spacing - if textareaHeight < 5 { - textareaHeight = 5 - } - if textareaHeight > 15 { - textareaHeight = 15 // Cap at reasonable height - } - - rb.bodyTextarea.SetSize(textareaWidth, textareaHeight) - rb.headersEditor.SetSize(textareaWidth, textareaHeight) - rb.queryEditor.SetSize(textareaWidth, textareaHeight) -} - -func (rb *RequestBuilder) Focus() { - rb.focused = true - // Don't auto-focus any component - user needs to explicitly focus in - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() -} - -func (rb *RequestBuilder) Blur() { - rb.focused = false - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() -} - -func (rb RequestBuilder) Focused() bool { - return rb.focused -} - -func (rb RequestBuilder) IsEditingComponent() bool { - return rb.componentFocused -} - -func (rb *RequestBuilder) LoadFromEndpoint(endpoint endpoints.EndpointEntity) { - rb.endpoint = &endpoint - rb.method = endpoint.Method - rb.url = endpoint.Url - rb.requestBody = endpoint.RequestBody - rb.bodyTextarea.SetValue(endpoint.RequestBody) - - // Load headers from JSON - if endpoint.Headers != "" { - var headersMap map[string]string - if err := json.Unmarshal([]byte(endpoint.Headers), &headersMap); err == nil { - var headerPairs []components.KeyValuePair - for k, v := range headersMap { - headerPairs = append(headerPairs, components.KeyValuePair{ - Key: k, - Value: v, - Enabled: true, - }) - } - rb.headersEditor.SetPairs(headerPairs) - } - } - - // Load query params from JSON - if endpoint.QueryParams != "" { - var queryMap map[string]string - if err := json.Unmarshal([]byte(endpoint.QueryParams), &queryMap); err == nil { - var queryPairs []components.KeyValuePair - for k, v := range queryMap { - queryPairs = append(queryPairs, components.KeyValuePair{ - Key: k, - Value: v, - Enabled: true, - }) - } - rb.queryEditor.SetPairs(queryPairs) - } - } - - // Make sure components are not focused by default - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() - rb.componentFocused = false -} - -func (rb RequestBuilder) Update(msg tea.Msg) (RequestBuilder, tea.Cmd) { - if !rb.focused { - return rb, nil - } - - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab", "shift+tab": - // Only handle tab switching if not editing a component - if !rb.componentFocused { - if msg.String() == "tab" { - rb.activeTab = (rb.activeTab + 1) % 3 - } else { - rb.activeTab = (rb.activeTab + 2) % 3 // Go backwards - } - } - case "enter": - if !rb.componentFocused { - // Focus into the current tab's component for editing - rb.componentFocused = true - switch rb.activeTab { - case RequestBodyTab: - rb.bodyTextarea.Focus() - case HeadersTab: - rb.headersEditor.Focus() - case QueryParamsTab: - rb.queryEditor.Focus() - } - } - case "esc": - // Exit component editing mode - if rb.componentFocused { - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() - } - } - } - - // Only update components if we're in component editing mode - if rb.componentFocused { - switch rb.activeTab { - case RequestBodyTab: - rb.bodyTextarea, cmd = rb.bodyTextarea.Update(msg) - case HeadersTab: - rb.headersEditor, cmd = rb.headersEditor.Update(msg) - case QueryParamsTab: - rb.queryEditor, cmd = rb.queryEditor.Update(msg) - } - } - - return rb, cmd -} - -func (rb RequestBuilder) View() string { - if rb.width < 10 || rb.height < 10 { - return "Request Builder (resize window)" - } - - var sections []string - - // Method and URL row - aligned properly - methodStyle := styles.ListItemStyle.Copy(). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Padding(0, 2). - Bold(true). - Height(1) - - urlStyle := styles.ListItemStyle.Copy(). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 2). - Width(rb.width - 20). - Height(1) - - methodView := methodStyle.Render(rb.method) - urlView := urlStyle.Render(rb.url) - methodUrlRow := lipgloss.JoinHorizontal(lipgloss.Center, methodView, " ", urlView) - sections = append(sections, methodUrlRow, "") - - // Tab headers - tabHeaders := rb.renderTabHeaders() - sections = append(sections, tabHeaders, "") - - // Tab content - tabContent := rb.renderTabContent() - sections = append(sections, tabContent) - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -func (rb RequestBuilder) renderTabHeaders() string { - tabs := []string{"Request Body", "Headers", "Query Params"} - var renderedTabs []string - - for i, tab := range tabs { - tabStyle := styles.ListItemStyle.Copy(). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary) - - if RequestBuilderTab(i) == rb.activeTab { - tabStyle = tabStyle. - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - } - - renderedTabs = append(renderedTabs, tabStyle.Render(tab)) - } - - tabsRow := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - return tabsRow -} - -func (rb RequestBuilder) renderTabContent() string { - switch rb.activeTab { - case RequestBodyTab: - return rb.bodyTextarea.View() - case HeadersTab: - return rb.headersEditor.View() - case QueryParamsTab: - return rb.queryEditor.View() - default: - return "" - } -} - -func (rb RequestBuilder) renderPlaceholderTab(message string) string { - // Calculate the same dimensions as the textarea - textareaWidth := int(float64(rb.width) * 0.9) - if textareaWidth > 120 { - textareaWidth = 120 - } - if textareaWidth < 60 { - textareaWidth = 60 - } - - textareaHeight := rb.height - 8 - if textareaHeight < 5 { - textareaHeight = 5 - } - if textareaHeight > 15 { - textareaHeight = 15 - } - - // Create a placeholder with the same structure as textarea (no label) - containerWidth := textareaWidth - 4 // Same calculation as textarea - if containerWidth < 20 { - containerWidth = 20 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(textareaHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Align(lipgloss.Center, lipgloss.Center) - - return container.Render(message) -} - -// Message types -type RequestSendMsg struct { - Method string - URL string - Body string -} diff --git a/internal/tui/views/selected_collection.go b/internal/tui/views/selected_collection.go deleted file mode 100644 index e505fe4..0000000 --- a/internal/tui/views/selected_collection.go +++ /dev/null @@ -1,276 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/backend/http" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type MainTab int - -const ( - RequestBuilderMainTab MainTab = iota - ResponseViewerMainTab -) - -type SelectedCollectionView struct { - layout components.Layout - endpointsManager *endpoints.EndpointsManager - httpManager *http.HTTPManager - collection collections.CollectionEntity - sidebar EndpointSidebarView - selectedEndpoint *endpoints.EndpointEntity - requestBuilder RequestBuilder - activeMainTab MainTab - width int - height int - notification string -} - -func NewSelectedCollectionView(endpointsManager *endpoints.EndpointsManager, httpManager *http.HTTPManager, collection collections.CollectionEntity) SelectedCollectionView { - sidebar := NewEndpointSidebarView(endpointsManager, collection) - sidebar.Focus() // Make sure sidebar starts focused - - return SelectedCollectionView{ - layout: components.NewLayout(), - endpointsManager: endpointsManager, - httpManager: httpManager, - collection: collection, - sidebar: sidebar, - selectedEndpoint: nil, - requestBuilder: NewRequestBuilder(), - activeMainTab: RequestBuilderMainTab, - } -} - -func NewSelectedCollectionViewWithSize(endpointsManager *endpoints.EndpointsManager, httpManager *http.HTTPManager, collection collections.CollectionEntity, width, height int) SelectedCollectionView { - layout := components.NewLayout() - layout.SetSize(width, height) - - windowWidth := int(float64(width) * 0.85) - windowHeight := int(float64(height) * 0.8) - innerWidth := windowWidth - 4 - innerHeight := windowHeight - 6 - sidebarWidth := innerWidth / 4 - - sidebar := NewEndpointSidebarView(endpointsManager, collection) - sidebar.width = sidebarWidth - sidebar.height = innerHeight - sidebar.Focus() // Make sure sidebar starts focused - - requestBuilder := NewRequestBuilder() - requestBuilder.SetSize(innerWidth-sidebarWidth-1, innerHeight) - - return SelectedCollectionView{ - layout: layout, - endpointsManager: endpointsManager, - httpManager: httpManager, - collection: collection, - sidebar: sidebar, - selectedEndpoint: nil, - requestBuilder: requestBuilder, - activeMainTab: RequestBuilderMainTab, - width: width, - height: height, - } -} - -func (v SelectedCollectionView) Init() tea.Cmd { - return v.sidebar.Init() -} - -func (v SelectedCollectionView) Update(msg tea.Msg) (SelectedCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - - windowWidth := int(float64(v.width) * 0.85) - windowHeight := int(float64(v.height) * 0.8) - innerWidth := windowWidth - 4 - innerHeight := windowHeight - 6 - sidebarWidth := innerWidth / 4 - - v.sidebar.width = sidebarWidth - v.sidebar.height = innerHeight - v.requestBuilder.SetSize(innerWidth-sidebarWidth-1, innerHeight) - - case tea.KeyMsg: - // Clear notification on any keypress - v.notification = "" - - // If request builder is in component editing mode, only handle esc - forward everything else - if v.activeMainTab == RequestBuilderMainTab && v.requestBuilder.IsEditingComponent() { - if msg.String() == "esc" { - // Forward the Esc to request builder to exit editing mode - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - return v, builderCmd - } - // Forward all other keys to request builder when editing - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - return v, builderCmd - } - - // Normal key handling when not editing - switch msg.String() { - case "esc", "q": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - case "1": - v.activeMainTab = RequestBuilderMainTab - v.requestBuilder.Focus() - case "2": - v.activeMainTab = ResponseViewerMainTab - v.requestBuilder.Blur() - case "a": - v.notification = "Adding endpoints is not yet implemented" - return v, nil - case "r": - v.notification = "Sending requests is not yet implemented" - return v, nil - } - - case EndpointSelectedMsg: - // Store the selected endpoint for display - v.selectedEndpoint = &msg.Endpoint - v.requestBuilder.LoadFromEndpoint(msg.Endpoint) - v.requestBuilder.Focus() - - case RequestSendMsg: - return v, nil - } - - // Forward messages to appropriate components (only if not editing) - if !(v.activeMainTab == RequestBuilderMainTab && v.requestBuilder.IsEditingComponent()) { - v.sidebar, cmd = v.sidebar.Update(msg) - - // Forward to request builder if it's the active tab - if v.activeMainTab == RequestBuilderMainTab { - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - if builderCmd != nil { - cmd = builderCmd - } - } - } - - return v, cmd -} - -func (v SelectedCollectionView) View() string { - title := "Collection: " + v.collection.Name - if v.selectedEndpoint != nil { - title += " > " + v.selectedEndpoint.Name - } - - sidebarContent := v.sidebar.View() - - // Main tab content - var mainContent string - if v.selectedEndpoint != nil { - // Show main tabs - tabsContent := v.renderMainTabs() - tabContent := v.renderMainTabContent() - mainContent = lipgloss.JoinVertical(lipgloss.Left, tabsContent, "", tabContent) - } else { - // Check if there are no endpoints at all - if len(v.sidebar.endpoints) == 0 { - mainContent = "Create an endpoint to get started" - } else { - mainContent = "Select an endpoint from the sidebar to view details" - } - } - - if v.width < 10 || v.height < 10 { - return v.layout.FullView(title, sidebarContent, "esc/q: back to collections") - } - - windowWidth := int(float64(v.width) * 0.85) - windowHeight := int(float64(v.height) * 0.8) - innerWidth := windowWidth - innerHeight := windowHeight - 6 - - sidebarWidth := innerWidth / 4 - mainWidth := innerWidth - sidebarWidth - 1 - - // Sidebar styling - sidebarStyle := styles.SidebarStyle.Copy(). - Width(sidebarWidth). - Height(innerHeight). - BorderForeground(styles.Primary) - - mainStyle := styles.MainContentStyle.Copy(). - Width(mainWidth). - Height(innerHeight) - - content := lipgloss.JoinHorizontal( - lipgloss.Top, - sidebarStyle.Render(sidebarContent), - mainStyle.Render(mainContent), - ) - - instructions := "↑↓: navigate endpoints • a: add endpoint • 1: request • 2: response • enter: edit • esc: stop editing • r: send • esc/q: back" - if v.notification != "" { - instructions = lipgloss.NewStyle(). - Foreground(styles.Warning). - Bold(true). - Render(v.notification) - } - - return v.layout.FullView( - title, - content, - instructions, - ) -} - -func (v SelectedCollectionView) renderMainTabs() string { - tabs := []string{"Request Builder", "Response Viewer"} - var renderedTabs []string - - for i, tab := range tabs { - tabStyle := styles.ListItemStyle.Copy(). - Padding(0, 3). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary) - - if MainTab(i) == v.activeMainTab { - tabStyle = tabStyle. - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - } - - renderedTabs = append(renderedTabs, tabStyle.Render(tab)) - } - - return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) -} - -func (v SelectedCollectionView) renderMainTabContent() string { - switch v.activeMainTab { - case RequestBuilderMainTab: - return v.requestBuilder.View() - case ResponseViewerMainTab: - return styles.ListItemStyle.Copy(). - Width(v.width/2). - Height(v.height/2). - Align(lipgloss.Center, lipgloss.Center). - Render("Yet to be implemented...") - default: - return "" - } -} - -// Message types for selected collection view -type EndpointSelectedMsg struct { - Endpoint endpoints.EndpointEntity -} diff --git a/main.go b/main.go index 8d527cc..04d1054 100644 --- a/main.go +++ b/main.go @@ -136,12 +136,12 @@ func main() { historyManager := history.NewHistoryManager(db) // create clean context for dependency injection - appContext := app.NewContext( - collectionsManager, - endpointsManager, - httpManager, - historyManager, - ) + // appContext := app.NewContext( + // collectionsManager, + // endpointsManager, + // httpManager, + // historyManager, + // ) // populate dummy data for demo demoGenerator := demo.NewDemoGenerator(collectionsManager, endpointsManager) @@ -149,7 +149,7 @@ func main() { if err != nil { log.Error("failed to populate dummy data", "error", err) } else if dummyDataCreated { - appContext.SetDummyDataCreated(true) + // appContext.SetDummyDataCreated(true) } log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging", "demo"}) @@ -157,7 +157,7 @@ func main() { log.Info("application started successfully") // Entry point for UI - program := tea.NewProgram(app.NewModel(appContext), tea.WithAltScreen()) + program := tea.NewProgram(app.NewAppModel(), tea.WithAltScreen()) if _, err := program.Run(); err != nil { log.Fatal("Fatal error:", err) } From 3bb9be3d5d2baa7eec0543b77e658abb98c7fb13 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Mon, 4 Aug 2025 19:04:02 +0530 Subject: [PATCH 02/27] app view modelled, some new packages created --- internal/tui/app/model.go | 16 +++++++++++++ internal/tui/keys/keys.go | 0 internal/tui/styles/app-styles.go | 9 +++++++ internal/tui/styles/colors.go | 9 +++++++ internal/tui/styles/functions.go | 39 +++++++++++++++++++++++++++++++ internal/tui/views/types.go | 15 ++++++++++++ 6 files changed, 88 insertions(+) create mode 100644 internal/tui/keys/keys.go create mode 100644 internal/tui/styles/app-styles.go create mode 100644 internal/tui/styles/colors.go create mode 100644 internal/tui/styles/functions.go create mode 100644 internal/tui/views/types.go diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 58d8c9f..aa7e81c 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -2,6 +2,8 @@ package app import ( tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/tui/styles" ) type AppModel struct { @@ -28,9 +30,23 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a AppModel) View() string { + footer := a.Footer() + header := a.Header() + availableHeight := a.height - lipgloss.Height(header) - lipgloss.Height(footer) + view := lipgloss.NewStyle().Height(availableHeight).Width(a.width).Align(lipgloss.Center, lipgloss.Center).Render("Hello world!") + return lipgloss.JoinVertical(lipgloss.Top, header, view, footer) +} + +func (a AppModel) Header() string { return "Hello World" } +func (a AppModel) Footer() string { + name := styles.GradientText("REQ", styles.FooterNameFGFrom, styles.FooterNameFGTo, styles.FooterNameStyle, styles.FooterNameBGStyle) + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name)).Render("v0.1.0-alpha2") + return lipgloss.JoinHorizontal(lipgloss.Left, name, version) +} + func NewAppModel() AppModel { return AppModel{} } diff --git a/internal/tui/keys/keys.go b/internal/tui/keys/keys.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go new file mode 100644 index 0000000..cc5dd64 --- /dev/null +++ b/internal/tui/styles/app-styles.go @@ -0,0 +1,9 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + FooterNameStyle = lipgloss.NewStyle().Bold(true) + FooterNameBGStyle = lipgloss.NewStyle().Background(FooterNameBG).Padding(0, 3, 0) + FooterVersionStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(lipgloss.Color("#656565")) +) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go new file mode 100644 index 0000000..5864b24 --- /dev/null +++ b/internal/tui/styles/colors.go @@ -0,0 +1,9 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + FooterNameBG = lipgloss.Color("#1a1a1a") + FooterNameFGFrom = lipgloss.Color("#6D51FE") + FooterNameFGTo = lipgloss.Color("#8B0F7D") +) diff --git a/internal/tui/styles/functions.go b/internal/tui/styles/functions.go new file mode 100644 index 0000000..905456a --- /dev/null +++ b/internal/tui/styles/functions.go @@ -0,0 +1,39 @@ +package styles + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func GradientText(text string, startColor, endColor lipgloss.Color, base, additional lipgloss.Style) string { + n := len(text) + result := "" + + for i := range n { + ratio := float64(i) / float64(n-1) + color := interpolateColor(startColor, endColor, ratio) + + style := base.Foreground(lipgloss.Color(color)) + result += style.Render(string(text[i])) + } + + return additional.Render(result) +} + +func interpolateColor(start, end lipgloss.Color, ratio float64) string { + r1, g1, b1 := hexToRGB(string(start)) + r2, g2, b2 := hexToRGB(string(end)) + + r := int(float64(r1) + (float64(r2)-float64(r1))*ratio) + g := int(float64(g1) + (float64(g2)-float64(g1))*ratio) + b := int(float64(b1) + (float64(b2)-float64(b1))*ratio) + + return fmt.Sprintf("#%02X%02X%02X", r, g, b) +} + +func hexToRGB(hex string) (int, int, int) { + var r, g, b int + fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) + return r, g, b +} diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go new file mode 100644 index 0000000..83da0e6 --- /dev/null +++ b/internal/tui/views/types.go @@ -0,0 +1,15 @@ +package views + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +type ViewInterface interface { + Init() tea.Cmd + Name() string + Help() string + Update(tea.Msg) (ViewInterface, tea.Cmd) + View() string + OnFocus() + OnBlur() +} From 03c267429cade70e3d4fba0411aec115cda3acdd Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Tue, 5 Aug 2025 18:38:11 +0530 Subject: [PATCH 03/27] changes: - created a options component for collections - created a config so that the list within the options is highly customizable - create a template for how the options component should operate --- internal/tui/app/model.go | 59 ++++++++++++--- .../components/OptionsProvider/component.go | 74 +++++++++++++++++++ .../tui/components/OptionsProvider/config.go | 26 +++++++ internal/tui/keybinds/keys.go | 37 ++++++++++ internal/tui/keys/keys.go | 0 internal/tui/styles/app-styles.go | 7 +- internal/tui/styles/colors.go | 8 +- internal/tui/views/collections-view-helper.go | 15 ++++ internal/tui/views/collections-view.go | 61 +++++++++++++++ internal/tui/views/types.go | 1 + main.go | 14 ++-- 11 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 internal/tui/components/OptionsProvider/component.go create mode 100644 internal/tui/components/OptionsProvider/config.go create mode 100644 internal/tui/keybinds/keys.go delete mode 100644 internal/tui/keys/keys.go create mode 100644 internal/tui/views/collections-view-helper.go create mode 100644 internal/tui/views/collections-view.go diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index aa7e81c..d5257f8 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -1,14 +1,26 @@ package app import ( + "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/tui/styles" + "github.com/maniac-en/req/internal/tui/views" +) + +type ViewName string + +const ( + Collections ViewName = "collections" ) type AppModel struct { - width int - height int + ctx *Context + width int + height int + Views map[ViewName]views.ViewInterface + focusedView ViewName } func (a AppModel) Init() tea.Cmd { @@ -16,37 +28,64 @@ func (a AppModel) Init() tea.Cmd { } func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: a.height = msg.Height a.width = msg.Width + a.Views[a.focusedView], cmd = a.Views[a.focusedView].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) + cmds = append(cmds, cmd) case tea.KeyMsg: switch msg.String() { case "ctrl+c": return a, tea.Quit } } - return a, nil + return a, tea.Batch(cmds...) } func (a AppModel) View() string { footer := a.Footer() header := a.Header() - availableHeight := a.height - lipgloss.Height(header) - lipgloss.Height(footer) - view := lipgloss.NewStyle().Height(availableHeight).Width(a.width).Align(lipgloss.Center, lipgloss.Center).Render("Hello world!") + view := a.Views[a.focusedView].View() return lipgloss.JoinVertical(lipgloss.Top, header, view, footer) } +func (a *AppModel) AvailableHeight() int { + footer := a.Footer() + header := a.Header() + return a.height - lipgloss.Height(header) - lipgloss.Height(footer) +} + func (a AppModel) Header() string { - return "Hello World" + var b strings.Builder + + for key, value := range a.Views { + if key == a.focusedView { + b.WriteString(styles.TabHeadingActive.Render(value.Name())) + } else { + b.WriteString(styles.TabHeadingInactive.Render(value.Name())) + } + } + b.WriteString(styles.TabHeadingInactive.Render("")) + return b.String() } func (a AppModel) Footer() string { name := styles.GradientText("REQ", styles.FooterNameFGFrom, styles.FooterNameFGTo, styles.FooterNameStyle, styles.FooterNameBGStyle) - version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name)).Render("v0.1.0-alpha2") - return lipgloss.JoinHorizontal(lipgloss.Left, name, version) + footerText := styles.FooterSegmentStyle.Render(a.Views[a.focusedView].GetFooterSegment()) + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha2") + return lipgloss.JoinHorizontal(lipgloss.Left, name, footerText, version) } -func NewAppModel() AppModel { - return AppModel{} +func NewAppModel(ctx *Context) AppModel { + model := AppModel{ + focusedView: Collections, + ctx: ctx, + } + model.Views = map[ViewName]views.ViewInterface{ + Collections: views.NewCollectionsView(model.ctx.Collections), + } + return model } diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go new file mode 100644 index 0000000..fb84410 --- /dev/null +++ b/internal/tui/components/OptionsProvider/component.go @@ -0,0 +1,74 @@ +package optionsProvider + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type OptionsProvider struct { + list list.Model + onSelectAction tea.Msg + width int + height int +} + +type Option struct { + title string + value string + description string +} + +func (o Option) Title() string { return o.title } +func (o Option) Description() string { return o.description } +func (o Option) Value() string { return o.value } +func (o Option) FilterValue() string { return o.title } + +func (o OptionsProvider) Init() tea.Cmd { + return nil +} + +func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + o.height = msg.Height + o.width = msg.Width + } + return o, nil +} + +func (o OptionsProvider) View() string { + return lipgloss.NewStyle().Height(o.height).Width(o.width).Align(lipgloss.Center, lipgloss.Center).Render("Hello world from select") +} + +func (o OptionsProvider) OnFocus() { + +} + +func (o OptionsProvider) OnBlur() { + +} + +func initList[T any](config *ListConfig[T]) list.Model { + + // items := config.ItemMapper(config.Items) + + list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + + // list configuration + list.SetFilteringEnabled(config.FilteringEnabled) + list.SetShowPagination(config.ShowPagination) + list.SetShowHelp(config.ShowHelp) + list.SetShowTitle(config.ShowTitle) + + // list.KeyMap = config.KeyMap + + return list +} + +func NewOptionsProvider[T any](config *ListConfig[T]) OptionsProvider { + return OptionsProvider{ + list: initList(config), + // onSelectAction: config.OnSelectAction, + } +} diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go new file mode 100644 index 0000000..0173fd7 --- /dev/null +++ b/internal/tui/components/OptionsProvider/config.go @@ -0,0 +1,26 @@ +package optionsProvider + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type ListConfig[T any] struct { + Items []T + OnSelectAction tea.Msg + + ShowPagination bool + ShowHelp bool + ShowTitle bool + Width, Height int + + FilteringEnabled bool + + Delegate list.ItemDelegate + KeyMap list.KeyMap + + ItemMapper func([]T) []list.Item + + // CrudOps Crud + // Style lipgloss.Style +} diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go new file mode 100644 index 0000000..ef5b22c --- /dev/null +++ b/internal/tui/keybinds/keys.go @@ -0,0 +1,37 @@ +package keybinds + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type Keymaps struct { + InsertItem key.Binding + DeleteItem key.Binding + EditItem key.Binding + Choose key.Binding + Remove key.Binding + Back key.Binding +} + +var Keys = Keymaps{ + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + InsertItem: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add item"), + ), + EditItem: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit item"), + ), + Choose: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Choose"), + ), + Remove: key.NewBinding( + key.WithKeys("x", "backspace"), + key.WithHelp("x", "delete"), + ), +} diff --git a/internal/tui/keys/keys.go b/internal/tui/keys/keys.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index cc5dd64..7da8fc1 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -1,9 +1,14 @@ package styles -import "github.com/charmbracelet/lipgloss" +import ( + "github.com/charmbracelet/lipgloss" +) var ( FooterNameStyle = lipgloss.NewStyle().Bold(true) FooterNameBGStyle = lipgloss.NewStyle().Background(FooterNameBG).Padding(0, 3, 0) + FooterSegmentStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).PaddingLeft(2).Foreground(lipgloss.Color("#656565")) FooterVersionStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(lipgloss.Color("#656565")) + TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + TabHeadingActive = lipgloss.NewStyle().Background(Accent).Foreground(HeadingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) ) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index 5864b24..204e580 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -3,7 +3,9 @@ package styles import "github.com/charmbracelet/lipgloss" var ( - FooterNameBG = lipgloss.Color("#1a1a1a") - FooterNameFGFrom = lipgloss.Color("#6D51FE") - FooterNameFGTo = lipgloss.Color("#8B0F7D") + FooterNameBG = lipgloss.Color("#1a1a1a") + FooterNameFGFrom = lipgloss.Color("#41A0AE") + FooterNameFGTo = lipgloss.Color("#77F07F") + Accent = lipgloss.Color("#77F07F") + HeadingForeground = lipgloss.Color("#000000") ) diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go new file mode 100644 index 0000000..5fc55d7 --- /dev/null +++ b/internal/tui/views/collections-view-helper.go @@ -0,0 +1,15 @@ +package views + +import ( + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" +) + +func defaultListConfig[T any]() *optionsProvider.ListConfig[T] { + config := optionsProvider.ListConfig[T]{ + ShowPagination: false, + ShowHelp: false, + ShowTitle: false, + FilteringEnabled: false, + } + return &config +} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go new file mode 100644 index 0000000..9d4f282 --- /dev/null +++ b/internal/tui/views/collections-view.go @@ -0,0 +1,61 @@ +package views + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/backend/collections" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" +) + +type CollectionsView struct { + width int + height int + list optionsProvider.OptionsProvider +} + +func (c CollectionsView) Init() tea.Cmd { + return nil +} + +func (c CollectionsView) Name() string { + return "Collections" +} + +func (c CollectionsView) Help() string { + return "" +} + +func (c CollectionsView) GetFooterSegment() string { + return "Collections" +} + +func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.height = msg.Height + c.width = msg.Width + c.list, cmd = c.list.Update(msg) + cmds = append(cmds, cmd) + } + return c, tea.Batch(cmds...) +} + +func (c CollectionsView) View() string { + return c.list.View() +} + +func (c CollectionsView) OnFocus() { + +} + +func (c CollectionsView) OnBlur() { + +} + +func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { + config := defaultListConfig[string]() + return &CollectionsView{ + list: optionsProvider.NewOptionsProvider(config), + } +} diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go index 83da0e6..f547d77 100644 --- a/internal/tui/views/types.go +++ b/internal/tui/views/types.go @@ -8,6 +8,7 @@ type ViewInterface interface { Init() tea.Cmd Name() string Help() string + GetFooterSegment() string Update(tea.Msg) (ViewInterface, tea.Cmd) View() string OnFocus() diff --git a/main.go b/main.go index 04d1054..90bd251 100644 --- a/main.go +++ b/main.go @@ -136,12 +136,12 @@ func main() { historyManager := history.NewHistoryManager(db) // create clean context for dependency injection - // appContext := app.NewContext( - // collectionsManager, - // endpointsManager, - // httpManager, - // historyManager, - // ) + appContext := app.NewContext( + collectionsManager, + endpointsManager, + httpManager, + historyManager, + ) // populate dummy data for demo demoGenerator := demo.NewDemoGenerator(collectionsManager, endpointsManager) @@ -157,7 +157,7 @@ func main() { log.Info("application started successfully") // Entry point for UI - program := tea.NewProgram(app.NewAppModel(), tea.WithAltScreen()) + program := tea.NewProgram(app.NewAppModel(appContext), tea.WithAltScreen()) if _, err := program.Run(); err != nil { log.Fatal("Fatal error:", err) } From bffb0499369dc910ac0c82fdcc5f9e6c68694b5c Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Wed, 6 Aug 2025 17:43:24 +0530 Subject: [PATCH 04/27] changes: - new sqlc func for get all collections - some bug fixes in the list config - changed existing list collections func to not use pagination - added a generic to list config for crud - defined the crud struct - added a crud struct to the collections view which is supplied to the list component --- db/queries/collections.sql | 4 +++ internal/backend/collections/manager.go | 10 ++++-- internal/backend/database/collections.sql.go | 33 +++++++++++++++++++ .../components/OptionsProvider/component.go | 11 ++++--- .../tui/components/OptionsProvider/config.go | 16 +++++++-- internal/tui/views/collections-view-helper.go | 7 ++-- internal/tui/views/collections-view.go | 9 ++++- 7 files changed, 75 insertions(+), 15 deletions(-) diff --git a/db/queries/collections.sql b/db/queries/collections.sql index 6eb988a..4f7eedc 100644 --- a/db/queries/collections.sql +++ b/db/queries/collections.sql @@ -6,6 +6,10 @@ SELECT * FROM collections ORDER BY created_at DESC LIMIT ? OFFSET ?; +-- name: GetCollections :many +SELECT * FROM collections +ORDER BY created_at DESC; + -- name: CountCollections :one SELECT COUNT(*) FROM collections; diff --git a/internal/backend/collections/manager.go b/internal/backend/collections/manager.go index 5c587d2..540bb35 100644 --- a/internal/backend/collections/manager.go +++ b/internal/backend/collections/manager.go @@ -96,12 +96,16 @@ func (c *CollectionsManager) Delete(ctx context.Context, id int64) error { } func (c *CollectionsManager) List(ctx context.Context) ([]CollectionEntity, error) { - log.Debug("listing all collections with default pagination") - paginated, err := c.ListPaginated(ctx, 50, 0) + log.Debug("listing all collections without pagination") + collections, err := c.DB.GetCollections(ctx) + collectionsEntity := []CollectionEntity{} + for _, collection := range collections { + collectionsEntity = append(collectionsEntity, CollectionEntity{Collection: collection}) + } if err != nil { return nil, err } - return paginated.Collections, nil + return collectionsEntity, nil } func (c *CollectionsManager) ListPaginated(ctx context.Context, limit, offset int) (*PaginatedCollections, error) { diff --git a/internal/backend/database/collections.sql.go b/internal/backend/database/collections.sql.go index 124ec33..799c0c3 100644 --- a/internal/backend/database/collections.sql.go +++ b/internal/backend/database/collections.sql.go @@ -63,6 +63,39 @@ func (q *Queries) GetCollection(ctx context.Context, id int64) (Collection, erro return i, err } +const getCollections = `-- name: GetCollections :many +SELECT id, name, created_at, updated_at FROM collections +ORDER BY created_at DESC +` + +func (q *Queries) GetCollections(ctx context.Context) ([]Collection, error) { + rows, err := q.db.QueryContext(ctx, getCollections) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Collection + for rows.Next() { + var i Collection + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getCollectionsPaginated = `-- name: GetCollectionsPaginated :many SELECT id, name, created_at, updated_at FROM collections ORDER BY created_at DESC diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index fb84410..1125343 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -3,7 +3,6 @@ package optionsProvider import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) type OptionsProvider struct { @@ -33,12 +32,13 @@ func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { case tea.WindowSizeMsg: o.height = msg.Height o.width = msg.Width + o.list.SetSize(o.list.Width(), o.height) } return o, nil } func (o OptionsProvider) View() string { - return lipgloss.NewStyle().Height(o.height).Width(o.width).Align(lipgloss.Center, lipgloss.Center).Render("Hello world from select") + return o.list.View() } func (o OptionsProvider) OnFocus() { @@ -49,14 +49,15 @@ func (o OptionsProvider) OnBlur() { } -func initList[T any](config *ListConfig[T]) list.Model { +func initList[T, C any](config *ListConfig[T, C]) list.Model { // items := config.ItemMapper(config.Items) - list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + list := list.New([]list.Item{}, list.NewDefaultDelegate(), 30, 0) // list configuration list.SetFilteringEnabled(config.FilteringEnabled) + list.SetShowStatusBar(config.ShowStatusBar) list.SetShowPagination(config.ShowPagination) list.SetShowHelp(config.ShowHelp) list.SetShowTitle(config.ShowTitle) @@ -66,7 +67,7 @@ func initList[T any](config *ListConfig[T]) list.Model { return list } -func NewOptionsProvider[T any](config *ListConfig[T]) OptionsProvider { +func NewOptionsProvider[T, C any](config *ListConfig[T, C]) OptionsProvider { return OptionsProvider{ list: initList(config), // onSelectAction: config.OnSelectAction, diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 0173fd7..368a3d1 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -1,15 +1,17 @@ package optionsProvider import ( + "context" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) -type ListConfig[T any] struct { - Items []T +type ListConfig[T, C any] struct { OnSelectAction tea.Msg ShowPagination bool + ShowStatusBar bool ShowHelp bool ShowTitle bool Width, Height int @@ -21,6 +23,14 @@ type ListConfig[T any] struct { ItemMapper func([]T) []list.Item - // CrudOps Crud + CrudOps Crud[C] // Style lipgloss.Style } + +type Crud[T any] struct { + Create func(context.Context, string) (T, error) + Read func(context.Context, int64) (T, error) + Update func(context.Context, int64, string) (T, error) + Delete func(context.Context, int64) error + List func(context.Context) ([]T, error) +} diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go index 5fc55d7..1f8f4d7 100644 --- a/internal/tui/views/collections-view-helper.go +++ b/internal/tui/views/collections-view-helper.go @@ -4,12 +4,13 @@ import ( optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" ) -func defaultListConfig[T any]() *optionsProvider.ListConfig[T] { - config := optionsProvider.ListConfig[T]{ +func defaultListConfig[T, C any]() *optionsProvider.ListConfig[T, C] { + config := optionsProvider.ListConfig[T, C]{ ShowPagination: false, + ShowStatusBar: false, ShowHelp: false, ShowTitle: false, - FilteringEnabled: false, + FilteringEnabled: true, } return &config } diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index 9d4f282..c2b1a3e 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -54,7 +54,14 @@ func (c CollectionsView) OnBlur() { } func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { - config := defaultListConfig[string]() + config := defaultListConfig[string, collections.CollectionEntity]() + config.CrudOps = optionsProvider.Crud[collections.CollectionEntity]{ + Create: collManager.Create, + Read: collManager.Read, + Update: collManager.Update, + Delete: collManager.Delete, + List: collManager.List, + } return &CollectionsView{ list: optionsProvider.NewOptionsProvider(config), } From 2c438d96780e52b90ac6ab92643de33560c8f0b7 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Thu, 7 Aug 2025 18:10:44 +0530 Subject: [PATCH 05/27] changes: - bug fixes in main model - made it so that colors are not exposed, only styles are - updated the update func for options provider to forward messages to the list component - set up the list crud op and the item mapper for collections view - changed generics in the list config so it makes more sense - updated the footer func for collections --- internal/tui/app/model.go | 9 +++- .../components/OptionsProvider/component.go | 48 ++++++++++++++----- .../tui/components/OptionsProvider/config.go | 10 ++-- internal/tui/styles/app-styles.go | 6 +-- internal/tui/styles/colors.go | 10 ++-- internal/tui/styles/functions.go | 6 ++- internal/tui/views/collections-view-helper.go | 4 +- internal/tui/views/collections-view.go | 25 ++++++++-- 8 files changed, 85 insertions(+), 33 deletions(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index d5257f8..7289df9 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -36,12 +36,17 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.width = msg.Width a.Views[a.focusedView], cmd = a.Views[a.focusedView].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) case tea.KeyMsg: switch msg.String() { case "ctrl+c": return a, tea.Quit } } + + a.Views[a.focusedView], cmd = a.Views[a.focusedView].Update(msg) + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) } @@ -73,9 +78,9 @@ func (a AppModel) Header() string { } func (a AppModel) Footer() string { - name := styles.GradientText("REQ", styles.FooterNameFGFrom, styles.FooterNameFGTo, styles.FooterNameStyle, styles.FooterNameBGStyle) + name := styles.ApplyGradientToFooter("REQ") footerText := styles.FooterSegmentStyle.Render(a.Views[a.focusedView].GetFooterSegment()) - version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha2") + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.2") return lipgloss.JoinHorizontal(lipgloss.Left, name, footerText, version) } diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 1125343..0fe18b8 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -1,6 +1,8 @@ package optionsProvider import ( + "context" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) @@ -13,28 +15,40 @@ type OptionsProvider struct { } type Option struct { - title string - value string - description string + Name string + ID int64 + Subtext string } -func (o Option) Title() string { return o.title } -func (o Option) Description() string { return o.description } -func (o Option) Value() string { return o.value } -func (o Option) FilterValue() string { return o.title } +func (o Option) Title() string { return o.Name } +func (o Option) Description() string { return o.Subtext } +func (o Option) Value() int64 { return o.ID } +func (o Option) FilterValue() string { return o.Name } func (o OptionsProvider) Init() tea.Cmd { return nil } func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: o.height = msg.Height o.width = msg.Width o.list.SetSize(o.list.Width(), o.height) + case tea.KeyMsg: + + o.list, cmd = o.list.Update(msg) + cmds = append(cmds, cmd) + + switch msg.String() { + + default: + } + } - return o, nil + return o, tea.Batch(cmds...) } func (o OptionsProvider) View() string { @@ -49,11 +63,21 @@ func (o OptionsProvider) OnBlur() { } -func initList[T, C any](config *ListConfig[T, C]) list.Model { +func (o OptionsProvider) GetSelected() Option { + return o.list.SelectedItem().(Option) +} + +func initList[T, U any](config *ListConfig[T, U]) list.Model { + + rawItems, err := config.CrudOps.List(context.Background()) + + if err != nil { + rawItems = []T{} + } - // items := config.ItemMapper(config.Items) + items := config.ItemMapper(rawItems) - list := list.New([]list.Item{}, list.NewDefaultDelegate(), 30, 0) + list := list.New(items, list.NewDefaultDelegate(), 30, 30) // list configuration list.SetFilteringEnabled(config.FilteringEnabled) @@ -67,7 +91,7 @@ func initList[T, C any](config *ListConfig[T, C]) list.Model { return list } -func NewOptionsProvider[T, C any](config *ListConfig[T, C]) OptionsProvider { +func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider { return OptionsProvider{ list: initList(config), // onSelectAction: config.OnSelectAction, diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 368a3d1..bbfe103 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -type ListConfig[T, C any] struct { +type ListConfig[T, U any] struct { OnSelectAction tea.Msg ShowPagination bool @@ -23,14 +23,14 @@ type ListConfig[T, C any] struct { ItemMapper func([]T) []list.Item - CrudOps Crud[C] + CrudOps Crud[T, U] // Style lipgloss.Style } -type Crud[T any] struct { - Create func(context.Context, string) (T, error) +type Crud[T, U any] struct { + Create func(context.Context, U) (T, error) Read func(context.Context, int64) (T, error) - Update func(context.Context, int64, string) (T, error) + Update func(context.Context, int64, U) (T, error) Delete func(context.Context, int64) error List func(context.Context) ([]T, error) } diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index 7da8fc1..d38834c 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -5,10 +5,10 @@ import ( ) var ( - FooterNameStyle = lipgloss.NewStyle().Bold(true) - FooterNameBGStyle = lipgloss.NewStyle().Background(FooterNameBG).Padding(0, 3, 0) + footerNameStyle = lipgloss.NewStyle().Bold(true) + footerNameBGStyle = lipgloss.NewStyle().Background(footerNameBG).Padding(0, 3, 0) FooterSegmentStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).PaddingLeft(2).Foreground(lipgloss.Color("#656565")) FooterVersionStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(lipgloss.Color("#656565")) TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) - TabHeadingActive = lipgloss.NewStyle().Background(Accent).Foreground(HeadingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) ) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index 204e580..a2a8c6a 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -3,9 +3,9 @@ package styles import "github.com/charmbracelet/lipgloss" var ( - FooterNameBG = lipgloss.Color("#1a1a1a") - FooterNameFGFrom = lipgloss.Color("#41A0AE") - FooterNameFGTo = lipgloss.Color("#77F07F") - Accent = lipgloss.Color("#77F07F") - HeadingForeground = lipgloss.Color("#000000") + footerNameBG = lipgloss.Color("#1a1a1a") + footerNameFGFrom = lipgloss.Color("#41A0AE") + footerNameFGTo = lipgloss.Color("#77F07F") + accent = lipgloss.Color("#77F07F") + headingForeground = lipgloss.Color("#000000") ) diff --git a/internal/tui/styles/functions.go b/internal/tui/styles/functions.go index 905456a..422250b 100644 --- a/internal/tui/styles/functions.go +++ b/internal/tui/styles/functions.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -func GradientText(text string, startColor, endColor lipgloss.Color, base, additional lipgloss.Style) string { +func gradientText(text string, startColor, endColor lipgloss.Color, base, additional lipgloss.Style) string { n := len(text) result := "" @@ -37,3 +37,7 @@ func hexToRGB(hex string) (int, int, int) { fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) return r, g, b } + +func ApplyGradientToFooter(text string) string { + return gradientText("REQ", footerNameFGFrom, footerNameFGTo, footerNameStyle, footerNameBGStyle) +} diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go index 1f8f4d7..23c371c 100644 --- a/internal/tui/views/collections-view-helper.go +++ b/internal/tui/views/collections-view-helper.go @@ -4,8 +4,8 @@ import ( optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" ) -func defaultListConfig[T, C any]() *optionsProvider.ListConfig[T, C] { - config := optionsProvider.ListConfig[T, C]{ +func defaultListConfig[T, U any]() *optionsProvider.ListConfig[T, U] { + config := optionsProvider.ListConfig[T, U]{ ShowPagination: false, ShowStatusBar: false, ShowHelp: false, diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index c2b1a3e..b0b8992 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -1,6 +1,7 @@ package views import ( + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" @@ -25,7 +26,7 @@ func (c CollectionsView) Help() string { } func (c CollectionsView) GetFooterSegment() string { - return "Collections" + return c.list.GetSelected().Title() } func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { @@ -38,6 +39,10 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { c.list, cmd = c.list.Update(msg) cmds = append(cmds, cmd) } + + c.list, cmd = c.list.Update(msg) + cmds = append(cmds, cmd) + return c, tea.Batch(cmds...) } @@ -53,15 +58,29 @@ func (c CollectionsView) OnBlur() { } +func itemMapper(items []collections.CollectionEntity) []list.Item { + opts := make([]list.Item, len(items)) + for i, item := range items { + newOpt := optionsProvider.Option{ + Name: item.GetName(), + Subtext: "Sample", + ID: item.GetID(), + } + opts[i] = newOpt + } + return opts +} + func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { - config := defaultListConfig[string, collections.CollectionEntity]() - config.CrudOps = optionsProvider.Crud[collections.CollectionEntity]{ + config := defaultListConfig[collections.CollectionEntity, string]() + config.CrudOps = optionsProvider.Crud[collections.CollectionEntity, string]{ Create: collManager.Create, Read: collManager.Read, Update: collManager.Update, Delete: collManager.Delete, List: collManager.List, } + config.ItemMapper = itemMapper return &CollectionsView{ list: optionsProvider.NewOptionsProvider(config), } From 23b2832c5074958633f002660d2dfb1cc6783898 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Fri, 8 Aug 2025 19:23:29 +0530 Subject: [PATCH 06/27] added endpoint count to collections and made it so that the db query returns endpoint counts --- db/queries/collections.sql | 12 +++++++-- internal/backend/collections/manager.go | 9 ++++++- internal/backend/collections/models.go | 5 ++++ internal/backend/database/collections.sql.go | 27 ++++++++++++++++---- internal/tui/views/collections-view.go | 4 ++- 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/db/queries/collections.sql b/db/queries/collections.sql index 4f7eedc..8ddc2d4 100644 --- a/db/queries/collections.sql +++ b/db/queries/collections.sql @@ -7,8 +7,16 @@ ORDER BY created_at DESC LIMIT ? OFFSET ?; -- name: GetCollections :many -SELECT * FROM collections -ORDER BY created_at DESC; +SELECT + c.id, + c.name, + c.created_at, + c.updated_at, + COUNT(e.id) AS endpoint_count +FROM collections c +LEFT JOIN endpoints e ON e.collection_id = c.id +GROUP BY c.id, c.name, c.created_at, c.updated_at +ORDER BY c.created_at DESC; -- name: CountCollections :one SELECT COUNT(*) FROM collections; diff --git a/internal/backend/collections/manager.go b/internal/backend/collections/manager.go index 540bb35..69326d4 100644 --- a/internal/backend/collections/manager.go +++ b/internal/backend/collections/manager.go @@ -100,7 +100,14 @@ func (c *CollectionsManager) List(ctx context.Context) ([]CollectionEntity, erro collections, err := c.DB.GetCollections(ctx) collectionsEntity := []CollectionEntity{} for _, collection := range collections { - collectionsEntity = append(collectionsEntity, CollectionEntity{Collection: collection}) + collectionsEntity = append(collectionsEntity, CollectionEntity{Collection: database.Collection{ + ID: collection.ID, + Name: collection.Name, + CreatedAt: collection.CreatedAt, + UpdatedAt: collection.UpdatedAt, + }, + EndpointCount: int(collection.EndpointCount), + }) } if err != nil { return nil, err diff --git a/internal/backend/collections/models.go b/internal/backend/collections/models.go index 94b46db..cdb60e6 100644 --- a/internal/backend/collections/models.go +++ b/internal/backend/collections/models.go @@ -9,6 +9,7 @@ import ( type CollectionEntity struct { database.Collection + EndpointCount int } func (c CollectionEntity) GetID() int64 { @@ -19,6 +20,10 @@ func (c CollectionEntity) GetName() string { return c.Name } +func (c CollectionEntity) GetEnpointCount() int { + return c.EndpointCount +} + func (c CollectionEntity) GetCreatedAt() time.Time { return crud.ParseTimestamp(c.CreatedAt) } diff --git a/internal/backend/database/collections.sql.go b/internal/backend/database/collections.sql.go index 799c0c3..3198766 100644 --- a/internal/backend/database/collections.sql.go +++ b/internal/backend/database/collections.sql.go @@ -64,24 +64,41 @@ func (q *Queries) GetCollection(ctx context.Context, id int64) (Collection, erro } const getCollections = `-- name: GetCollections :many -SELECT id, name, created_at, updated_at FROM collections -ORDER BY created_at DESC +SELECT + c.id, + c.name, + c.created_at, + c.updated_at, + COUNT(e.id) AS endpoint_count +FROM collections c +LEFT JOIN endpoints e ON e.collection_id = c.id +GROUP BY c.id, c.name, c.created_at, c.updated_at +ORDER BY c.created_at DESC ` -func (q *Queries) GetCollections(ctx context.Context) ([]Collection, error) { +type GetCollectionsRow struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + CreatedAt string `db:"created_at" json:"created_at"` + UpdatedAt string `db:"updated_at" json:"updated_at"` + EndpointCount int64 `db:"endpoint_count" json:"endpoint_count"` +} + +func (q *Queries) GetCollections(ctx context.Context) ([]GetCollectionsRow, error) { rows, err := q.db.QueryContext(ctx, getCollections) if err != nil { return nil, err } defer rows.Close() - var items []Collection + var items []GetCollectionsRow for rows.Next() { - var i Collection + var i GetCollectionsRow if err := rows.Scan( &i.ID, &i.Name, &i.CreatedAt, &i.UpdatedAt, + &i.EndpointCount, ); err != nil { return nil, err } diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index b0b8992..edd8b1f 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -1,6 +1,8 @@ package views import ( + "fmt" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" @@ -63,7 +65,7 @@ func itemMapper(items []collections.CollectionEntity) []list.Item { for i, item := range items { newOpt := optionsProvider.Option{ Name: item.GetName(), - Subtext: "Sample", + Subtext: fmt.Sprintf("%d endpoints", item.GetEnpointCount()), ID: item.GetID(), } opts[i] = newOpt From ba724d2c63211abd32552ab582d4bf7d2d807715 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Fri, 8 Aug 2025 20:04:52 +0530 Subject: [PATCH 07/27] changes: - styling for the list component - added and input component for the options provider --- .../components/OptionsProvider/component.go | 53 +++++++++++++---- .../tui/components/OptionsProvider/input.go | 58 +++++++++++++++++++ internal/tui/styles/collections-styles.go | 7 +++ internal/tui/views/collections-view-helper.go | 13 +++++ 4 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 internal/tui/components/OptionsProvider/input.go create mode 100644 internal/tui/styles/collections-styles.go diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 0fe18b8..8bfe855 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -3,15 +3,27 @@ package optionsProvider import ( "context" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/tui/keybinds" +) + +type focusedComp string + +const ( + listComponent = "list" + textComponent = "text" ) type OptionsProvider struct { list list.Model + input OptionsInput onSelectAction tea.Msg width int height int + focused focusedComp } type Option struct { @@ -38,25 +50,38 @@ func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { o.width = msg.Width o.list.SetSize(o.list.Width(), o.height) case tea.KeyMsg: - - o.list, cmd = o.list.Update(msg) - cmds = append(cmds, cmd) - - switch msg.String() { - - default: + switch o.focused { + case listComponent: + if !o.IsFiltering() { + switch { + case key.Matches(msg, keybinds.Keys.InsertItem): + o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) + o.input.OnFocus() + o.focused = textComponent + return o, tea.Batch(cmds...) + } + } } } + switch o.focused { + case listComponent: + o.list, cmd = o.list.Update(msg) + case textComponent: + o.input, cmd = o.input.Update(msg) + } + cmds = append(cmds, cmd) return o, tea.Batch(cmds...) } func (o OptionsProvider) View() string { + if o.focused == textComponent { + return lipgloss.JoinVertical(lipgloss.Left, o.list.View(), o.input.View()) + } return o.list.View() } -func (o OptionsProvider) OnFocus() { - +func (o *OptionsProvider) OnFocus() { } func (o OptionsProvider) OnBlur() { @@ -67,6 +92,10 @@ func (o OptionsProvider) GetSelected() Option { return o.list.SelectedItem().(Option) } +func (o OptionsProvider) IsFiltering() bool { + return o.list.FilterState() == list.Filtering +} + func initList[T, U any](config *ListConfig[T, U]) list.Model { rawItems, err := config.CrudOps.List(context.Background()) @@ -77,7 +106,7 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { items := config.ItemMapper(rawItems) - list := list.New(items, list.NewDefaultDelegate(), 30, 30) + list := list.New(items, config.Delegate, 30, 30) // list configuration list.SetFilteringEnabled(config.FilteringEnabled) @@ -93,7 +122,9 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider { return OptionsProvider{ - list: initList(config), + list: initList(config), + focused: listComponent, + input: NewOptionsInput(), // onSelectAction: config.OnSelectAction, } } diff --git a/internal/tui/components/OptionsProvider/input.go b/internal/tui/components/OptionsProvider/input.go new file mode 100644 index 0000000..5dcc6d3 --- /dev/null +++ b/internal/tui/components/OptionsProvider/input.go @@ -0,0 +1,58 @@ +package optionsProvider + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type OptionsInput struct { + input textinput.Model + height int + width int + editId int +} + +func NewOptionsInput() OptionsInput { + input := textinput.New() + input.CharLimit = 100 + input.Placeholder = "Add New Collection..." + input.Width = 22 + return OptionsInput{ + input: input, + editId: -1, + } +} + +func (i OptionsInput) Init() tea.Cmd { + return nil +} + +func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + i.input, cmd = i.input.Update(msg) + cmds = append(cmds, cmd) + + return i, tea.Batch(cmds...) +} + +func (i OptionsInput) View() string { + return i.input.View() +} + +func (i *OptionsInput) SetInput(text string) { + i.input.SetValue(text) +} + +func (i *OptionsInput) OnFocus(id ...int) { + if len(id) > 0 { + i.editId = id[0] + } + i.input.Focus() +} + +func (i *OptionsInput) OnBlur() { + i.editId = -1 + i.input.Blur() +} diff --git a/internal/tui/styles/collections-styles.go b/internal/tui/styles/collections-styles.go new file mode 100644 index 0000000..c1c2daa --- /dev/null +++ b/internal/tui/styles/collections-styles.go @@ -0,0 +1,7 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + SelectedListStyle = lipgloss.NewStyle().Foreground(accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(accent) +) diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go index 23c371c..479072c 100644 --- a/internal/tui/views/collections-view-helper.go +++ b/internal/tui/views/collections-view-helper.go @@ -1,9 +1,20 @@ package views import ( + "github.com/charmbracelet/bubbles/list" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/styles" ) +func createDelegate() list.DefaultDelegate { + d := list.NewDefaultDelegate() + + d.Styles.SelectedTitle = styles.SelectedListStyle + d.Styles.SelectedDesc = styles.SelectedListStyle + + return d +} + func defaultListConfig[T, U any]() *optionsProvider.ListConfig[T, U] { config := optionsProvider.ListConfig[T, U]{ ShowPagination: false, @@ -11,6 +22,8 @@ func defaultListConfig[T, U any]() *optionsProvider.ListConfig[T, U] { ShowHelp: false, ShowTitle: false, FilteringEnabled: true, + + Delegate: createDelegate(), } return &config } From 765149b1f88a11b71775f107ea292377ce700826 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 9 Aug 2025 15:01:39 +0530 Subject: [PATCH 08/27] changes: - created a messages package for circulating messages - removed crud from the config and gave it access to only the list func - added generics to the OptionsProvider - styled the input and added functionality to add collections - created and input config - added some styling attributes - extracted some colors from the styles and made vars out of them --- .../components/OptionsProvider/component.go | 50 +++++++++++++------ .../tui/components/OptionsProvider/config.go | 13 +++-- .../tui/components/OptionsProvider/input.go | 24 +++++++-- internal/tui/messages/messages.go | 5 ++ internal/tui/styles/app-styles.go | 4 +- internal/tui/styles/collections-styles.go | 1 + internal/tui/styles/colors.go | 2 + internal/tui/views/collections-view.go | 24 +++++---- 8 files changed, 84 insertions(+), 39 deletions(-) create mode 100644 internal/tui/messages/messages.go diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 8bfe855..08f20e7 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" ) type focusedComp string @@ -17,13 +18,15 @@ const ( textComponent = "text" ) -type OptionsProvider struct { +type OptionsProvider[T, U any] struct { list list.Model input OptionsInput onSelectAction tea.Msg width int height int focused focusedComp + getItems func(context.Context) ([]T, error) + itemMapper func([]T) []list.Item } type Option struct { @@ -37,11 +40,11 @@ func (o Option) Description() string { return o.Subtext } func (o Option) Value() int64 { return o.ID } func (o Option) FilterValue() string { return o.Name } -func (o OptionsProvider) Init() tea.Cmd { +func (o OptionsProvider[T, U]) Init() tea.Cmd { return nil } -func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { +func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { @@ -62,6 +65,15 @@ func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { } } } + case messages.ItemAdded: + o.input.OnBlur() + o.focused = listComponent + o.list.SetSize(o.list.Width(), o.height) + newItems, err := o.getItems(context.Background()) + if err != nil { + + } + o.list.SetItems(o.itemMapper(newItems)) } switch o.focused { @@ -74,31 +86,31 @@ func (o OptionsProvider) Update(msg tea.Msg) (OptionsProvider, tea.Cmd) { return o, tea.Batch(cmds...) } -func (o OptionsProvider) View() string { +func (o OptionsProvider[T, U]) View() string { if o.focused == textComponent { return lipgloss.JoinVertical(lipgloss.Left, o.list.View(), o.input.View()) } return o.list.View() } -func (o *OptionsProvider) OnFocus() { +func (o *OptionsProvider[T, U]) OnFocus() { } -func (o OptionsProvider) OnBlur() { +func (o OptionsProvider[T, U]) OnBlur() { } -func (o OptionsProvider) GetSelected() Option { +func (o OptionsProvider[T, U]) GetSelected() Option { return o.list.SelectedItem().(Option) } -func (o OptionsProvider) IsFiltering() bool { +func (o OptionsProvider[T, U]) IsFiltering() bool { return o.list.FilterState() == list.Filtering } func initList[T, U any](config *ListConfig[T, U]) list.Model { - rawItems, err := config.CrudOps.List(context.Background()) + rawItems, err := config.GetItemsFunc(context.Background()) if err != nil { rawItems = []T{} @@ -120,11 +132,21 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { return list } -func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider { - return OptionsProvider{ - list: initList(config), - focused: listComponent, - input: NewOptionsInput(), +func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { + + inputConfig := InputConfig{ + CharLimit: 100, + Placeholder: "Add A New Collection...", + Width: 22, + Prompt: "", + } + + return OptionsProvider[T, U]{ + list: initList(config), + focused: listComponent, + input: NewOptionsInput(&inputConfig), + getItems: config.GetItemsFunc, + itemMapper: config.ItemMapper, // onSelectAction: config.OnSelectAction, } } diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index bbfe103..bd4ceb1 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -23,14 +23,13 @@ type ListConfig[T, U any] struct { ItemMapper func([]T) []list.Item - CrudOps Crud[T, U] + GetItemsFunc func(context.Context) ([]T, error) // Style lipgloss.Style } -type Crud[T, U any] struct { - Create func(context.Context, U) (T, error) - Read func(context.Context, int64) (T, error) - Update func(context.Context, int64, U) (T, error) - Delete func(context.Context, int64) error - List func(context.Context) ([]T, error) +type InputConfig struct { + Prompt string + Placeholder string + CharLimit int + Width int } diff --git a/internal/tui/components/OptionsProvider/input.go b/internal/tui/components/OptionsProvider/input.go index 5dcc6d3..d69358f 100644 --- a/internal/tui/components/OptionsProvider/input.go +++ b/internal/tui/components/OptionsProvider/input.go @@ -1,8 +1,12 @@ package optionsProvider import ( + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" + "github.com/maniac-en/req/internal/tui/styles" ) type OptionsInput struct { @@ -12,11 +16,14 @@ type OptionsInput struct { editId int } -func NewOptionsInput() OptionsInput { +func NewOptionsInput(config *InputConfig) OptionsInput { input := textinput.New() - input.CharLimit = 100 - input.Placeholder = "Add New Collection..." - input.Width = 22 + input.CharLimit = config.CharLimit + input.Placeholder = config.Placeholder + input.Width = config.Width + input.TextStyle = styles.InputStyle + input.Prompt = config.Prompt + return OptionsInput{ input: input, editId: -1, @@ -30,6 +37,13 @@ func (i OptionsInput) Init() tea.Cmd { func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, keybinds.Keys.Choose): + return i, func() tea.Msg { return messages.ItemAdded{Item: i.input.Value()} } + } + } i.input, cmd = i.input.Update(msg) cmds = append(cmds, cmd) @@ -38,7 +52,7 @@ func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { } func (i OptionsInput) View() string { - return i.input.View() + return styles.InputStyle.Render(i.input.View()) } func (i *OptionsInput) SetInput(text string) { diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go new file mode 100644 index 0000000..c4f3427 --- /dev/null +++ b/internal/tui/messages/messages.go @@ -0,0 +1,5 @@ +package messages + +type ItemAdded struct { + Item string +} diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index d38834c..ce1b22a 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -7,8 +7,8 @@ import ( var ( footerNameStyle = lipgloss.NewStyle().Bold(true) footerNameBGStyle = lipgloss.NewStyle().Background(footerNameBG).Padding(0, 3, 0) - FooterSegmentStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).PaddingLeft(2).Foreground(lipgloss.Color("#656565")) - FooterVersionStyle = lipgloss.NewStyle().Background(lipgloss.Color("#262626")).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(lipgloss.Color("#656565")) + FooterSegmentStyle = lipgloss.NewStyle().Background(footerSegmentBG).PaddingLeft(2).Foreground(footerSegmentFG) + FooterVersionStyle = lipgloss.NewStyle().Background(footerSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(footerSegmentFG) TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) ) diff --git a/internal/tui/styles/collections-styles.go b/internal/tui/styles/collections-styles.go index c1c2daa..600e885 100644 --- a/internal/tui/styles/collections-styles.go +++ b/internal/tui/styles/collections-styles.go @@ -4,4 +4,5 @@ import "github.com/charmbracelet/lipgloss" var ( SelectedListStyle = lipgloss.NewStyle().Foreground(accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(accent) + InputStyle = lipgloss.NewStyle().Padding(1, 2).Border(lipgloss.NormalBorder(), false, false, false, true).Margin(1, 0) ) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index a2a8c6a..65dfbed 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -8,4 +8,6 @@ var ( footerNameFGTo = lipgloss.Color("#77F07F") accent = lipgloss.Color("#77F07F") headingForeground = lipgloss.Color("#000000") + footerSegmentBG = lipgloss.Color("#262626") + footerSegmentFG = lipgloss.Color("#656565") ) diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index edd8b1f..d1ea8fc 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -1,18 +1,21 @@ package views import ( + "context" "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/messages" ) type CollectionsView struct { - width int - height int - list optionsProvider.OptionsProvider + width int + height int + list optionsProvider.OptionsProvider[collections.CollectionEntity, string] + manager *collections.CollectionsManager } func (c CollectionsView) Init() tea.Cmd { @@ -40,6 +43,8 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { c.width = msg.Width c.list, cmd = c.list.Update(msg) cmds = append(cmds, cmd) + case messages.ItemAdded: + c.manager.Create(context.Background(), msg.Item) } c.list, cmd = c.list.Update(msg) @@ -75,15 +80,12 @@ func itemMapper(items []collections.CollectionEntity) []list.Item { func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { config := defaultListConfig[collections.CollectionEntity, string]() - config.CrudOps = optionsProvider.Crud[collections.CollectionEntity, string]{ - Create: collManager.Create, - Read: collManager.Read, - Update: collManager.Update, - Delete: collManager.Delete, - List: collManager.List, - } + + config.GetItemsFunc = collManager.List config.ItemMapper = itemMapper + return &CollectionsView{ - list: optionsProvider.NewOptionsProvider(config), + list: optionsProvider.NewOptionsProvider(config), + manager: collManager, } } From 5b3b803031b8734077adff621a7be59023c40295 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 9 Aug 2025 20:07:23 +0530 Subject: [PATCH 09/27] added delete collection --- .../components/OptionsProvider/component.go | 19 +++++++++++++------ internal/tui/messages/messages.go | 3 +++ internal/tui/views/collections-view.go | 3 +++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 08f20e7..6590ad1 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/log" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" ) @@ -62,6 +63,8 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C o.input.OnFocus() o.focused = textComponent return o, tea.Batch(cmds...) + case key.Matches(msg, keybinds.Keys.Remove): + return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } } } } @@ -69,12 +72,7 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C o.input.OnBlur() o.focused = listComponent o.list.SetSize(o.list.Width(), o.height) - newItems, err := o.getItems(context.Background()) - if err != nil { - - } - o.list.SetItems(o.itemMapper(newItems)) - + o.RefreshItems() } switch o.focused { case listComponent: @@ -108,6 +106,15 @@ func (o OptionsProvider[T, U]) IsFiltering() bool { return o.list.FilterState() == list.Filtering } +func (o *OptionsProvider[T, U]) RefreshItems() { + newItems, err := o.getItems(context.Background()) + if err != nil { + log.Warn("Fetching items failed") + return + } + o.list.SetItems(o.itemMapper(newItems)) +} + func initList[T, U any](config *ListConfig[T, U]) list.Model { rawItems, err := config.GetItemsFunc(context.Background()) diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index c4f3427..fa283dd 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -3,3 +3,6 @@ package messages type ItemAdded struct { Item string } +type DeleteItem struct { + ItemID int64 +} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index d1ea8fc..aba0256 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -45,6 +45,9 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { cmds = append(cmds, cmd) case messages.ItemAdded: c.manager.Create(context.Background(), msg.Item) + case messages.DeleteItem: + c.manager.Delete(context.Background(), msg.ItemID) + c.list.RefreshItems() } c.list, cmd = c.list.Update(msg) From 74ba6535eea356100e4ddafb256c0863fe10e679 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 9 Aug 2025 20:24:44 +0530 Subject: [PATCH 10/27] added edit collection --- .../tui/components/OptionsProvider/component.go | 12 +++++++++++- internal/tui/components/OptionsProvider/input.go | 13 ++++++++++--- internal/tui/messages/messages.go | 5 +++++ internal/tui/views/collections-view.go | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 6590ad1..20b57f7 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -65,14 +65,24 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C return o, tea.Batch(cmds...) case key.Matches(msg, keybinds.Keys.Remove): return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } + case key.Matches(msg, keybinds.Keys.EditItem): + o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) + o.input.SetInput(o.GetSelected().Name) + o.input.OnFocus(o.GetSelected().ID) + o.focused = textComponent + return o, tea.Batch(cmds...) } } } - case messages.ItemAdded: + case messages.ItemAdded, messages.ItemEdited: o.input.OnBlur() o.focused = listComponent o.list.SetSize(o.list.Width(), o.height) o.RefreshItems() + case messages.DeactivateView: + o.input.OnBlur() + o.focused = listComponent + o.list.SetSize(o.list.Width(), o.height) } switch o.focused { case listComponent: diff --git a/internal/tui/components/OptionsProvider/input.go b/internal/tui/components/OptionsProvider/input.go index d69358f..62e4909 100644 --- a/internal/tui/components/OptionsProvider/input.go +++ b/internal/tui/components/OptionsProvider/input.go @@ -13,7 +13,7 @@ type OptionsInput struct { input textinput.Model height int width int - editId int + editId int64 } func NewOptionsInput(config *InputConfig) OptionsInput { @@ -41,7 +41,14 @@ func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, keybinds.Keys.Choose): - return i, func() tea.Msg { return messages.ItemAdded{Item: i.input.Value()} } + itemName := i.input.Value() + i.input.SetValue("") + if i.editId == -1 { + return i, func() tea.Msg { return messages.ItemAdded{Item: itemName} } + } + return i, func() tea.Msg { return messages.ItemEdited{Item: itemName, ItemID: i.editId} } + case key.Matches(msg, keybinds.Keys.Back): + return i, func() tea.Msg { return messages.DeactivateView{} } } } @@ -59,7 +66,7 @@ func (i *OptionsInput) SetInput(text string) { i.input.SetValue(text) } -func (i *OptionsInput) OnFocus(id ...int) { +func (i *OptionsInput) OnFocus(id ...int64) { if len(id) > 0 { i.editId = id[0] } diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index fa283dd..91b32bc 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -3,6 +3,11 @@ package messages type ItemAdded struct { Item string } +type ItemEdited struct { + Item string + ItemID int64 +} type DeleteItem struct { ItemID int64 } +type DeactivateView struct{} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index aba0256..ab05d1d 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -45,6 +45,8 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { cmds = append(cmds, cmd) case messages.ItemAdded: c.manager.Create(context.Background(), msg.Item) + case messages.ItemEdited: + c.manager.Update(context.Background(), msg.ItemID, msg.Item) case messages.DeleteItem: c.manager.Delete(context.Background(), msg.ItemID) c.list.RefreshItems() From eb1fa71c93f484b27a43fe9706cff6975001504d Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 9 Aug 2025 21:24:09 +0530 Subject: [PATCH 11/27] input extracted into its own package to make it more reusable --- internal/tui/components/Input/config.go | 8 ++++++++ .../tui/components/{OptionsProvider => Input}/input.go | 2 +- internal/tui/components/OptionsProvider/component.go | 7 ++++--- internal/tui/components/OptionsProvider/config.go | 7 ------- 4 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 internal/tui/components/Input/config.go rename internal/tui/components/{OptionsProvider => Input}/input.go (98%) diff --git a/internal/tui/components/Input/config.go b/internal/tui/components/Input/config.go new file mode 100644 index 0000000..41e141f --- /dev/null +++ b/internal/tui/components/Input/config.go @@ -0,0 +1,8 @@ +package input + +type InputConfig struct { + Prompt string + Placeholder string + CharLimit int + Width int +} diff --git a/internal/tui/components/OptionsProvider/input.go b/internal/tui/components/Input/input.go similarity index 98% rename from internal/tui/components/OptionsProvider/input.go rename to internal/tui/components/Input/input.go index 62e4909..f4c012a 100644 --- a/internal/tui/components/OptionsProvider/input.go +++ b/internal/tui/components/Input/input.go @@ -1,4 +1,4 @@ -package optionsProvider +package input import ( "github.com/charmbracelet/bubbles/key" diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 20b57f7..2272109 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/log" + input "github.com/maniac-en/req/internal/tui/components/Input" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" ) @@ -21,7 +22,7 @@ const ( type OptionsProvider[T, U any] struct { list list.Model - input OptionsInput + input input.OptionsInput onSelectAction tea.Msg width int height int @@ -151,7 +152,7 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { - inputConfig := InputConfig{ + inputConfig := input.InputConfig{ CharLimit: 100, Placeholder: "Add A New Collection...", Width: 22, @@ -161,7 +162,7 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U return OptionsProvider[T, U]{ list: initList(config), focused: listComponent, - input: NewOptionsInput(&inputConfig), + input: input.NewOptionsInput(&inputConfig), getItems: config.GetItemsFunc, itemMapper: config.ItemMapper, // onSelectAction: config.OnSelectAction, diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index bd4ceb1..34472ae 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -26,10 +26,3 @@ type ListConfig[T, U any] struct { GetItemsFunc func(context.Context) ([]T, error) // Style lipgloss.Style } - -type InputConfig struct { - Prompt string - Placeholder string - CharLimit int - Width int -} From 05b8fb15821a585e6501d5f4544cdd171f3f37f9 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 9 Aug 2025 23:52:41 +0530 Subject: [PATCH 12/27] added help menu --- internal/tui/app/model.go | 20 ++++-- .../components/OptionsProvider/component.go | 11 +-- .../tui/components/OptionsProvider/config.go | 6 +- internal/tui/keybinds/collections-binds.go | 43 ++++++++++++ internal/tui/keybinds/keys.go | 67 +++++++++++++++++-- internal/tui/styles/app-styles.go | 2 + internal/tui/styles/colors.go | 1 + internal/tui/views/collections-view-helper.go | 18 +++-- internal/tui/views/collections-view.go | 13 +++- 9 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 internal/tui/keybinds/collections-binds.go diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 7289df9..b887a7f 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -3,8 +3,11 @@ package app import ( "strings" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/styles" "github.com/maniac-en/req/internal/tui/views" ) @@ -21,6 +24,7 @@ type AppModel struct { height int Views map[ViewName]views.ViewInterface focusedView ViewName + help help.Model } func (a AppModel) Init() tea.Cmd { @@ -38,8 +42,8 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return a, tea.Batch(cmds...) case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": + switch { + case key.Matches(msg, keybinds.Keys.Quit): return a, tea.Quit } } @@ -54,13 +58,20 @@ func (a AppModel) View() string { footer := a.Footer() header := a.Header() view := a.Views[a.focusedView].View() - return lipgloss.JoinVertical(lipgloss.Top, header, view, footer) + help := a.Help() + return lipgloss.JoinVertical(lipgloss.Top, header, view, help, footer) +} + +func (a AppModel) Help() string { + appHelp := styles.AppHelpStyle.Render(" • " + a.help.View(keybinds.Keys)) + return lipgloss.JoinHorizontal(lipgloss.Left, a.Views[a.focusedView].Help(), appHelp) } func (a *AppModel) AvailableHeight() int { footer := a.Footer() header := a.Header() - return a.height - lipgloss.Height(header) - lipgloss.Height(footer) + help := a.Views[a.focusedView].Help() + return a.height - lipgloss.Height(header) - lipgloss.Height(footer) - lipgloss.Height(help) } func (a AppModel) Header() string { @@ -88,6 +99,7 @@ func NewAppModel(ctx *Context) AppModel { model := AppModel{ focusedView: Collections, ctx: ctx, + help: help.New(), } model.Views = map[ViewName]views.ViewInterface{ Collections: views.NewCollectionsView(model.ctx.Collections), diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 2272109..53b23e3 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -24,6 +24,7 @@ type OptionsProvider[T, U any] struct { list list.Model input input.OptionsInput onSelectAction tea.Msg + keys *keybinds.ListKeyMap width int height int focused focusedComp @@ -59,14 +60,14 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C case listComponent: if !o.IsFiltering() { switch { - case key.Matches(msg, keybinds.Keys.InsertItem): + case key.Matches(msg, o.keys.AddItem): o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) o.input.OnFocus() o.focused = textComponent return o, tea.Batch(cmds...) - case key.Matches(msg, keybinds.Keys.Remove): + case key.Matches(msg, o.keys.DeleteItem): return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } - case key.Matches(msg, keybinds.Keys.EditItem): + case key.Matches(msg, o.keys.EditItem): o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) o.input.SetInput(o.GetSelected().Name) o.input.OnFocus(o.GetSelected().ID) @@ -144,8 +145,7 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { list.SetShowPagination(config.ShowPagination) list.SetShowHelp(config.ShowHelp) list.SetShowTitle(config.ShowTitle) - - // list.KeyMap = config.KeyMap + list.KeyMap = config.KeyMap return list } @@ -165,6 +165,7 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U input: input.NewOptionsInput(&inputConfig), getItems: config.GetItemsFunc, itemMapper: config.ItemMapper, + keys: config.AdditionalKeymaps, // onSelectAction: config.OnSelectAction, } } diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 34472ae..07f0fbf 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/tui/keybinds" ) type ListConfig[T, U any] struct { @@ -18,8 +19,9 @@ type ListConfig[T, U any] struct { FilteringEnabled bool - Delegate list.ItemDelegate - KeyMap list.KeyMap + Delegate list.ItemDelegate + KeyMap list.KeyMap + AdditionalKeymaps *keybinds.ListKeyMap ItemMapper func([]T) []list.Item diff --git a/internal/tui/keybinds/collections-binds.go b/internal/tui/keybinds/collections-binds.go new file mode 100644 index 0000000..71a546f --- /dev/null +++ b/internal/tui/keybinds/collections-binds.go @@ -0,0 +1,43 @@ +package keybinds + +import "github.com/charmbracelet/bubbles/key" + +type ListKeyMap struct { + CursorUp key.Binding + CursorDown key.Binding + NextPage key.Binding + PrevPage key.Binding + Filter key.Binding + ClearFilter key.Binding + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + AddItem key.Binding + EditItem key.Binding + DeleteItem key.Binding +} + +func (c ListKeyMap) ShortHelp() []key.Binding { + return []key.Binding{c.CursorUp, c.CursorDown, c.NextPage, c.PrevPage, c.AddItem, c.EditItem, c.DeleteItem} +} + +func (c ListKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.CursorUp, c.CursorDown, c.NextPage, c.PrevPage}, + } +} + +func NewListKeyMap() *ListKeyMap { + return &ListKeyMap{ + CursorUp: Keys.Up, + CursorDown: Keys.Down, + NextPage: Keys.NextPage, + PrevPage: Keys.PrevPage, + Filter: Keys.Filter, + ClearFilter: Keys.ClearFilter, + CancelWhileFiltering: Keys.CancelWhileFiltering, + AcceptWhileFiltering: Keys.AcceptWhileFiltering, + AddItem: Keys.InsertItem, + DeleteItem: Keys.Remove, + EditItem: Keys.EditItem, + } +} diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go index ef5b22c..f2123a5 100644 --- a/internal/tui/keybinds/keys.go +++ b/internal/tui/keybinds/keys.go @@ -5,12 +5,31 @@ import ( ) type Keymaps struct { - InsertItem key.Binding - DeleteItem key.Binding - EditItem key.Binding - Choose key.Binding - Remove key.Binding - Back key.Binding + InsertItem key.Binding + DeleteItem key.Binding + EditItem key.Binding + Choose key.Binding + Remove key.Binding + Back key.Binding + Up key.Binding + Down key.Binding + NextPage key.Binding + PrevPage key.Binding + Filter key.Binding + ClearFilter key.Binding + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + Quit key.Binding +} + +func (k Keymaps) ShortHelp() []key.Binding { + return []key.Binding{ + k.Quit, + } +} + +func (k Keymaps) FullHelp() [][]key.Binding { + return [][]key.Binding{} } var Keys = Keymaps{ @@ -18,6 +37,38 @@ var Keys = Keymaps{ key.WithKeys("esc"), key.WithHelp("esc", "back"), ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "back"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left", "h", "pgup"), + key.WithHelp("←/h/pgup", "prev page"), + ), + NextPage: key.NewBinding( + key.WithKeys("right", "l", "pgdown"), + key.WithHelp("→/l/pgdn", "next page"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + CancelWhileFiltering: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + AcceptWhileFiltering: key.NewBinding( + key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), + key.WithHelp("enter", "apply filter"), + ), InsertItem: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add item"), @@ -34,4 +85,8 @@ var Keys = Keymaps{ key.WithKeys("x", "backspace"), key.WithHelp("x", "delete"), ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), } diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index ce1b22a..bfb31c3 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -11,4 +11,6 @@ var ( FooterVersionStyle = lipgloss.NewStyle().Background(footerSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(footerSegmentFG) TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + HelpStyle = lipgloss.NewStyle().Padding(1, 0, 1, 2) + AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(helpFG) ) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index 65dfbed..9cb8232 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -10,4 +10,5 @@ var ( headingForeground = lipgloss.Color("#000000") footerSegmentBG = lipgloss.Color("#262626") footerSegmentFG = lipgloss.Color("#656565") + helpFG = lipgloss.Color("#3C3C3C") ) diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go index 479072c..410b0e0 100644 --- a/internal/tui/views/collections-view-helper.go +++ b/internal/tui/views/collections-view-helper.go @@ -3,6 +3,7 @@ package views import ( "github.com/charmbracelet/bubbles/list" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/styles" ) @@ -15,15 +16,24 @@ func createDelegate() list.DefaultDelegate { return d } -func defaultListConfig[T, U any]() *optionsProvider.ListConfig[T, U] { +func defaultListConfig[T, U any](binds *keybinds.ListKeyMap) *optionsProvider.ListConfig[T, U] { config := optionsProvider.ListConfig[T, U]{ - ShowPagination: false, + ShowPagination: true, ShowStatusBar: false, ShowHelp: false, ShowTitle: false, FilteringEnabled: true, - - Delegate: createDelegate(), + Delegate: createDelegate(), + KeyMap: list.KeyMap{ + CursorUp: binds.CursorUp, + CursorDown: binds.CursorDown, + NextPage: binds.NextPage, + PrevPage: binds.PrevPage, + Filter: binds.Filter, + ClearFilter: binds.ClearFilter, + CancelWhileFiltering: binds.CancelWhileFiltering, + AcceptWhileFiltering: binds.AcceptWhileFiltering, + }, } return &config } diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index ab05d1d..ffaa4ce 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -4,11 +4,14 @@ import ( "context" "fmt" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" + "github.com/maniac-en/req/internal/tui/styles" ) type CollectionsView struct { @@ -16,6 +19,8 @@ type CollectionsView struct { height int list optionsProvider.OptionsProvider[collections.CollectionEntity, string] manager *collections.CollectionsManager + help help.Model + keys *keybinds.ListKeyMap } func (c CollectionsView) Init() tea.Cmd { @@ -27,7 +32,7 @@ func (c CollectionsView) Name() string { } func (c CollectionsView) Help() string { - return "" + return styles.HelpStyle.Render(c.help.View(c.keys)) } func (c CollectionsView) GetFooterSegment() string { @@ -84,13 +89,17 @@ func itemMapper(items []collections.CollectionEntity) []list.Item { } func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { - config := defaultListConfig[collections.CollectionEntity, string]() + keybinds := keybinds.NewListKeyMap() + config := defaultListConfig[collections.CollectionEntity, string](keybinds) config.GetItemsFunc = collManager.List config.ItemMapper = itemMapper + config.AdditionalKeymaps = keybinds return &CollectionsView{ list: optionsProvider.NewOptionsProvider(config), manager: collManager, + help: help.New(), + keys: keybinds, } } From 96de05f25165fbd8034cf5cccfbd34642ca84e8f Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sun, 10 Aug 2025 13:48:36 +0530 Subject: [PATCH 13/27] help menu is now structured in a better way --- internal/tui/app/model.go | 16 ++++++-- internal/tui/components/Input/config.go | 8 ++++ internal/tui/components/Input/input.go | 14 +++++-- .../components/OptionsProvider/component.go | 41 +++++++++++++++++++ internal/tui/keybinds/collections-binds.go | 4 ++ internal/tui/keybinds/types.go | 22 ++++++++++ internal/tui/views/collections-view.go | 6 +-- internal/tui/views/types.go | 3 +- 8 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 internal/tui/keybinds/types.go diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index b887a7f..bfe1668 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -24,6 +24,7 @@ type AppModel struct { height int Views map[ViewName]views.ViewInterface focusedView ViewName + keys []key.Binding help help.Model } @@ -63,14 +64,18 @@ func (a AppModel) View() string { } func (a AppModel) Help() string { - appHelp := styles.AppHelpStyle.Render(" • " + a.help.View(keybinds.Keys)) - return lipgloss.JoinHorizontal(lipgloss.Left, a.Views[a.focusedView].Help(), appHelp) + viewHelp := a.Views[a.focusedView].Help() + appHelp := append(viewHelp, a.keys...) + helpStruct := keybinds.Help{ + Keys: appHelp, + } + return styles.HelpStyle.Render(a.help.View(helpStruct)) } func (a *AppModel) AvailableHeight() int { footer := a.Footer() header := a.Header() - help := a.Views[a.focusedView].Help() + help := a.Help() return a.height - lipgloss.Height(header) - lipgloss.Height(footer) - lipgloss.Height(help) } @@ -96,10 +101,15 @@ func (a AppModel) Footer() string { } func NewAppModel(ctx *Context) AppModel { + appKeybinds := []key.Binding{ + keybinds.Keys.Quit, + } + model := AppModel{ focusedView: Collections, ctx: ctx, help: help.New(), + keys: appKeybinds, } model.Views = map[ViewName]views.ViewInterface{ Collections: views.NewCollectionsView(model.ctx.Collections), diff --git a/internal/tui/components/Input/config.go b/internal/tui/components/Input/config.go index 41e141f..8d444d3 100644 --- a/internal/tui/components/Input/config.go +++ b/internal/tui/components/Input/config.go @@ -1,8 +1,16 @@ package input +import "github.com/charmbracelet/bubbles/key" + type InputConfig struct { Prompt string Placeholder string CharLimit int Width int + KeyMap InputKeyMaps +} + +type InputKeyMaps struct { + Accept key.Binding + Back key.Binding } diff --git a/internal/tui/components/Input/input.go b/internal/tui/components/Input/input.go index f4c012a..5124693 100644 --- a/internal/tui/components/Input/input.go +++ b/internal/tui/components/Input/input.go @@ -4,7 +4,6 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" "github.com/maniac-en/req/internal/tui/styles" ) @@ -14,6 +13,7 @@ type OptionsInput struct { height int width int editId int64 + keys InputKeyMaps } func NewOptionsInput(config *InputConfig) OptionsInput { @@ -27,6 +27,7 @@ func NewOptionsInput(config *InputConfig) OptionsInput { return OptionsInput{ input: input, editId: -1, + keys: config.KeyMap, } } @@ -40,14 +41,14 @@ func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { - case key.Matches(msg, keybinds.Keys.Choose): + case key.Matches(msg, i.keys.Accept): itemName := i.input.Value() i.input.SetValue("") if i.editId == -1 { return i, func() tea.Msg { return messages.ItemAdded{Item: itemName} } } return i, func() tea.Msg { return messages.ItemEdited{Item: itemName, ItemID: i.editId} } - case key.Matches(msg, keybinds.Keys.Back): + case key.Matches(msg, i.keys.Back): return i, func() tea.Msg { return messages.DeactivateView{} } } } @@ -62,6 +63,13 @@ func (i OptionsInput) View() string { return styles.InputStyle.Render(i.input.View()) } +func (i OptionsInput) Help() []key.Binding { + return []key.Binding{ + i.keys.Accept, + i.keys.Back, + } +} + func (i *OptionsInput) SetInput(text string) { i.input.SetValue(text) } diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 53b23e3..1280260 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -111,6 +111,13 @@ func (o OptionsProvider[T, U]) OnBlur() { } func (o OptionsProvider[T, U]) GetSelected() Option { + if o.IsFiltering() { + return Option{ + Name: "Filtering....", + ID: -1, + Subtext: "", + } + } return o.list.SelectedItem().(Option) } @@ -127,6 +134,36 @@ func (o *OptionsProvider[T, U]) RefreshItems() { o.list.SetItems(o.itemMapper(newItems)) } +func (o *OptionsProvider[T, U]) Help() []key.Binding { + var binds []key.Binding + switch o.focused { + case listComponent: + if o.IsFiltering() { + binds = []key.Binding{ + o.keys.AcceptWhileFiltering, + o.keys.CancelWhileFiltering, + o.keys.ClearFilter, + } + } else { + binds = []key.Binding{ + o.keys.CursorUp, + o.keys.CursorDown, + o.keys.NextPage, + o.keys.PrevPage, + o.keys.Filter, + o.keys.AddItem, + o.keys.EditItem, + o.keys.DeleteItem, + } + } + case textComponent: + binds = o.input.Help() + default: + binds = []key.Binding{} + } + return binds +} + func initList[T, U any](config *ListConfig[T, U]) list.Model { rawItems, err := config.GetItemsFunc(context.Background()) @@ -157,6 +194,10 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U Placeholder: "Add A New Collection...", Width: 22, Prompt: "", + KeyMap: input.InputKeyMaps{ + Accept: config.AdditionalKeymaps.Accept, + Back: config.AdditionalKeymaps.Back, + }, } return OptionsProvider[T, U]{ diff --git a/internal/tui/keybinds/collections-binds.go b/internal/tui/keybinds/collections-binds.go index 71a546f..84c9310 100644 --- a/internal/tui/keybinds/collections-binds.go +++ b/internal/tui/keybinds/collections-binds.go @@ -14,6 +14,8 @@ type ListKeyMap struct { AddItem key.Binding EditItem key.Binding DeleteItem key.Binding + Accept key.Binding + Back key.Binding } func (c ListKeyMap) ShortHelp() []key.Binding { @@ -39,5 +41,7 @@ func NewListKeyMap() *ListKeyMap { AddItem: Keys.InsertItem, DeleteItem: Keys.Remove, EditItem: Keys.EditItem, + Accept: Keys.Choose, + Back: Keys.Back, } } diff --git a/internal/tui/keybinds/types.go b/internal/tui/keybinds/types.go new file mode 100644 index 0000000..b360631 --- /dev/null +++ b/internal/tui/keybinds/types.go @@ -0,0 +1,22 @@ +package keybinds + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type Help struct { + Keys []key.Binding +} + +func (h Help) ShortHelp() []key.Binding { + return h.Keys +} + +func (h Help) FullHelp() [][]key.Binding { + // TODO: Figure how you wanna show this + return [][]key.Binding{} +} + +func (h Help) SetHelp(helpMenu []key.Binding) { + h.Keys = helpMenu +} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index ffaa4ce..0656115 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -5,13 +5,13 @@ import ( "fmt" "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" - "github.com/maniac-en/req/internal/tui/styles" ) type CollectionsView struct { @@ -31,8 +31,8 @@ func (c CollectionsView) Name() string { return "Collections" } -func (c CollectionsView) Help() string { - return styles.HelpStyle.Render(c.help.View(c.keys)) +func (c CollectionsView) Help() []key.Binding { + return c.list.Help() } func (c CollectionsView) GetFooterSegment() string { diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go index f547d77..11564ea 100644 --- a/internal/tui/views/types.go +++ b/internal/tui/views/types.go @@ -1,13 +1,14 @@ package views import ( + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) type ViewInterface interface { Init() tea.Cmd Name() string - Help() string + Help() []key.Binding GetFooterSegment() string Update(tea.Msg) (ViewInterface, tea.Cmd) View() string From bdbccbef680ae4da81fd5361eb990424a342338b Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sun, 10 Aug 2025 14:35:00 +0530 Subject: [PATCH 14/27] changes: - added the boilerplate for an endpoints view - added a way to order views so that they dont switch around in the heading --- internal/tui/app/model.go | 41 +++++++++++-- .../components/OptionsProvider/component.go | 2 + internal/tui/keybinds/collections-binds.go | 2 + internal/tui/messages/messages.go | 4 ++ internal/tui/views/collections-view.go | 8 ++- internal/tui/views/endpoints-view.go | 60 +++++++++++++++++++ internal/tui/views/types.go | 1 + 7 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 internal/tui/views/endpoints-view.go diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index bfe1668..9681713 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -1,6 +1,7 @@ package app import ( + "sort" "strings" "github.com/charmbracelet/bubbles/help" @@ -8,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" "github.com/maniac-en/req/internal/tui/styles" "github.com/maniac-en/req/internal/tui/views" ) @@ -16,8 +18,14 @@ type ViewName string const ( Collections ViewName = "collections" + Endpoints ViewName = "endpoints" ) +type Heading struct { + name string + order int +} + type AppModel struct { ctx *Context width int @@ -39,9 +47,15 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: a.height = msg.Height a.width = msg.Width - a.Views[a.focusedView], cmd = a.Views[a.focusedView].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) + for key, _ := range a.Views { + a.Views[key], cmd = a.Views[key].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) + cmds = append(cmds, cmd) + } cmds = append(cmds, cmd) return a, tea.Batch(cmds...) + case messages.ChooseCollection: + a.focusedView = Endpoints + return a, tea.Batch(cmds...) case tea.KeyMsg: switch { case key.Matches(msg, keybinds.Keys.Quit): @@ -82,14 +96,28 @@ func (a *AppModel) AvailableHeight() int { func (a AppModel) Header() string { var b strings.Builder - for key, value := range a.Views { - if key == a.focusedView { - b.WriteString(styles.TabHeadingActive.Render(value.Name())) + // INFO: this might be a bit messy, could be a nice idea to look into OrderedMaps maybe? + views := []Heading{} + for key := range a.Views { + views = append(views, Heading{ + name: a.Views[key].Name(), + order: a.Views[key].Order(), + }) + } + sort.Slice(views, func(i, j int) bool { + return views[i].order < views[j].order + }) + + for _, item := range views { + if item.name == a.Views[a.focusedView].Name() { + b.WriteString(styles.TabHeadingActive.Render(item.name)) } else { - b.WriteString(styles.TabHeadingInactive.Render(value.Name())) + b.WriteString(styles.TabHeadingInactive.Render(item.name)) } } + b.WriteString(styles.TabHeadingInactive.Render("")) + return b.String() } @@ -112,7 +140,8 @@ func NewAppModel(ctx *Context) AppModel { keys: appKeybinds, } model.Views = map[ViewName]views.ViewInterface{ - Collections: views.NewCollectionsView(model.ctx.Collections), + Collections: views.NewCollectionsView(model.ctx.Collections, 1), + Endpoints: views.NewEndpointsView(2), } return model } diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 1280260..44934c2 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -67,6 +67,8 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C return o, tea.Batch(cmds...) case key.Matches(msg, o.keys.DeleteItem): return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } + case key.Matches(msg, o.keys.Choose): + return o, func() tea.Msg { return messages.ChooseCollection{} } case key.Matches(msg, o.keys.EditItem): o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) o.input.SetInput(o.GetSelected().Name) diff --git a/internal/tui/keybinds/collections-binds.go b/internal/tui/keybinds/collections-binds.go index 84c9310..ed8c972 100644 --- a/internal/tui/keybinds/collections-binds.go +++ b/internal/tui/keybinds/collections-binds.go @@ -14,6 +14,7 @@ type ListKeyMap struct { AddItem key.Binding EditItem key.Binding DeleteItem key.Binding + Choose key.Binding Accept key.Binding Back key.Binding } @@ -41,6 +42,7 @@ func NewListKeyMap() *ListKeyMap { AddItem: Keys.InsertItem, DeleteItem: Keys.Remove, EditItem: Keys.EditItem, + Choose: Keys.Choose, Accept: Keys.Choose, Back: Keys.Back, } diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 91b32bc..1dfa97d 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -7,7 +7,11 @@ type ItemEdited struct { Item string ItemID int64 } + type DeleteItem struct { ItemID int64 } + +type ChooseCollection struct{} + type DeactivateView struct{} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index 0656115..a774e2f 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -21,6 +21,7 @@ type CollectionsView struct { manager *collections.CollectionsManager help help.Model keys *keybinds.ListKeyMap + order int } func (c CollectionsView) Init() tea.Cmd { @@ -67,6 +68,10 @@ func (c CollectionsView) View() string { return c.list.View() } +func (c CollectionsView) Order() int { + return c.order +} + func (c CollectionsView) OnFocus() { } @@ -88,7 +93,7 @@ func itemMapper(items []collections.CollectionEntity) []list.Item { return opts } -func NewCollectionsView(collManager *collections.CollectionsManager) *CollectionsView { +func NewCollectionsView(collManager *collections.CollectionsManager, order int) *CollectionsView { keybinds := keybinds.NewListKeyMap() config := defaultListConfig[collections.CollectionEntity, string](keybinds) @@ -101,5 +106,6 @@ func NewCollectionsView(collManager *collections.CollectionsManager) *Collection manager: collManager, help: help.New(), keys: keybinds, + order: order, } } diff --git a/internal/tui/views/endpoints-view.go b/internal/tui/views/endpoints-view.go new file mode 100644 index 0000000..5c15070 --- /dev/null +++ b/internal/tui/views/endpoints-view.go @@ -0,0 +1,60 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type EndpointsView struct { + height int + width int + order int +} + +func (e EndpointsView) Init() tea.Cmd { + return nil +} + +func (e EndpointsView) Name() string { + return "Endpoints" +} + +func (e EndpointsView) Help() []key.Binding { + return []key.Binding{} +} + +func (e EndpointsView) GetFooterSegment() string { + return "" +} + +func (e EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + e.height = msg.Height + e.width = msg.Width + } + return e, nil +} + +func (e EndpointsView) View() string { + return lipgloss.NewStyle().Height(e.height).Width(e.width).Align(lipgloss.Center, lipgloss.Center).Render("Endpoints View!") +} + +func (e EndpointsView) OnFocus() { + +} + +func (e EndpointsView) OnBlur() { + +} + +func (e EndpointsView) Order() int { + return e.order +} + +func NewEndpointsView(order int) *EndpointsView { + return &EndpointsView{ + order: order, + } +} diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go index 11564ea..c6da38e 100644 --- a/internal/tui/views/types.go +++ b/internal/tui/views/types.go @@ -12,6 +12,7 @@ type ViewInterface interface { GetFooterSegment() string Update(tea.Msg) (ViewInterface, tea.Cmd) View() string + Order() int OnFocus() OnBlur() } From 0c7ee6b6f7f3cf06d620091e9a112742a19feaad Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sun, 10 Aug 2025 15:53:47 +0530 Subject: [PATCH 15/27] input moved back into options provider (behaviour too specific to generalise into a component) --- internal/tui/components/Input/config.go | 16 ---------------- .../tui/components/OptionsProvider/component.go | 9 ++++----- .../tui/components/OptionsProvider/config.go | 14 ++++++++++++++ .../{Input => OptionsProvider}/input.go | 2 +- internal/tui/keybinds/keys.go | 10 ---------- 5 files changed, 19 insertions(+), 32 deletions(-) delete mode 100644 internal/tui/components/Input/config.go rename internal/tui/components/{Input => OptionsProvider}/input.go (98%) diff --git a/internal/tui/components/Input/config.go b/internal/tui/components/Input/config.go deleted file mode 100644 index 8d444d3..0000000 --- a/internal/tui/components/Input/config.go +++ /dev/null @@ -1,16 +0,0 @@ -package input - -import "github.com/charmbracelet/bubbles/key" - -type InputConfig struct { - Prompt string - Placeholder string - CharLimit int - Width int - KeyMap InputKeyMaps -} - -type InputKeyMaps struct { - Accept key.Binding - Back key.Binding -} diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index 44934c2..b21e4de 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -8,7 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/log" - input "github.com/maniac-en/req/internal/tui/components/Input" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" ) @@ -22,7 +21,7 @@ const ( type OptionsProvider[T, U any] struct { list list.Model - input input.OptionsInput + input OptionsInput onSelectAction tea.Msg keys *keybinds.ListKeyMap width int @@ -191,12 +190,12 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { - inputConfig := input.InputConfig{ + inputConfig := InputConfig{ CharLimit: 100, Placeholder: "Add A New Collection...", Width: 22, Prompt: "", - KeyMap: input.InputKeyMaps{ + KeyMap: InputKeyMaps{ Accept: config.AdditionalKeymaps.Accept, Back: config.AdditionalKeymaps.Back, }, @@ -205,7 +204,7 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U return OptionsProvider[T, U]{ list: initList(config), focused: listComponent, - input: input.NewOptionsInput(&inputConfig), + input: NewOptionsInput(&inputConfig), getItems: config.GetItemsFunc, itemMapper: config.ItemMapper, keys: config.AdditionalKeymaps, diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 07f0fbf..361ee0e 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -2,6 +2,7 @@ package optionsProvider import ( "context" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -28,3 +29,16 @@ type ListConfig[T, U any] struct { GetItemsFunc func(context.Context) ([]T, error) // Style lipgloss.Style } + +type InputConfig struct { + Prompt string + Placeholder string + CharLimit int + Width int + KeyMap InputKeyMaps +} + +type InputKeyMaps struct { + Accept key.Binding + Back key.Binding +} diff --git a/internal/tui/components/Input/input.go b/internal/tui/components/OptionsProvider/input.go similarity index 98% rename from internal/tui/components/Input/input.go rename to internal/tui/components/OptionsProvider/input.go index 5124693..2a8a84b 100644 --- a/internal/tui/components/Input/input.go +++ b/internal/tui/components/OptionsProvider/input.go @@ -1,4 +1,4 @@ -package input +package optionsProvider import ( "github.com/charmbracelet/bubbles/key" diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go index f2123a5..b4bb527 100644 --- a/internal/tui/keybinds/keys.go +++ b/internal/tui/keybinds/keys.go @@ -22,16 +22,6 @@ type Keymaps struct { Quit key.Binding } -func (k Keymaps) ShortHelp() []key.Binding { - return []key.Binding{ - k.Quit, - } -} - -func (k Keymaps) FullHelp() [][]key.Binding { - return [][]key.Binding{} -} - var Keys = Keymaps{ Back: key.NewBinding( key.WithKeys("esc"), From 2664cdad1f1367cf4a05a7137a56fe60419c2dd7 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Tue, 12 Aug 2025 22:25:03 +0530 Subject: [PATCH 16/27] changes: - added a new db query for listing all endpoints by collection (non paginated) - created a new manager function for fetching these - renamed the old function responsible for listing eps - changed the tests for the old function to make sure the still work - slightly modified the choose items message to make it work better when sent by different views - more work on the ep view to make it display eps - the addition of a set state function to the interface to set the state of certain views --- db/queries/endpoints.sql | 5 + internal/backend/database/endpoints.sql.go | 40 +++++++ internal/backend/endpoints/manager.go | 25 ++++- internal/backend/endpoints/manager_test.go | 10 +- internal/tui/app/model.go | 17 ++- .../components/OptionsProvider/component.go | 20 +++- .../tui/components/OptionsProvider/config.go | 2 + internal/tui/messages/messages.go | 5 +- internal/tui/views/collections-view.go | 6 ++ internal/tui/views/endpoints-view.go | 100 ++++++++++++++---- internal/tui/views/types.go | 1 + 11 files changed, 199 insertions(+), 32 deletions(-) diff --git a/db/queries/endpoints.sql b/db/queries/endpoints.sql index d379d3f..a4766e9 100644 --- a/db/queries/endpoints.sql +++ b/db/queries/endpoints.sql @@ -16,6 +16,11 @@ RETURNING *; SELECT * FROM endpoints WHERE id = ? LIMIT 1; +-- name: ListEndpointsByCollection :many +SELECT * FROM endpoints +WHERE collection_id = ? +ORDER BY created_at DESC; + -- name: ListEndpointsPaginated :many SELECT * FROM endpoints WHERE collection_id = ? diff --git a/internal/backend/database/endpoints.sql.go b/internal/backend/database/endpoints.sql.go index 3c9433a..59c2925 100644 --- a/internal/backend/database/endpoints.sql.go +++ b/internal/backend/database/endpoints.sql.go @@ -105,6 +105,46 @@ func (q *Queries) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { return i, err } +const listEndpointsByCollection = `-- name: ListEndpointsByCollection :many +SELECT id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at FROM endpoints +WHERE collection_id = ? +ORDER BY created_at DESC +` + +func (q *Queries) ListEndpointsByCollection(ctx context.Context, collectionID int64) ([]Endpoint, error) { + rows, err := q.db.QueryContext(ctx, listEndpointsByCollection, collectionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Endpoint + for rows.Next() { + var i Endpoint + if err := rows.Scan( + &i.ID, + &i.CollectionID, + &i.Name, + &i.Method, + &i.Url, + &i.Headers, + &i.QueryParams, + &i.RequestBody, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listEndpointsPaginated = `-- name: ListEndpointsPaginated :many SELECT id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at FROM endpoints WHERE collection_id = ? diff --git a/internal/backend/endpoints/manager.go b/internal/backend/endpoints/manager.go index 196b15f..8a30a75 100644 --- a/internal/backend/endpoints/manager.go +++ b/internal/backend/endpoints/manager.go @@ -64,7 +64,7 @@ func (e *EndpointsManager) List(ctx context.Context) ([]EndpointEntity, error) { return nil, fmt.Errorf("use ListByCollection to list endpoints for a specific collection") } -func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID int64, limit, offset int) (*PaginatedEndpoints, error) { +func (e *EndpointsManager) ListByCollectionByPage(ctx context.Context, collectionID int64, limit, offset int) (*PaginatedEndpoints, error) { if err := crud.ValidateID(collectionID); err != nil { log.Warn("endpoint list failed collection validation", "collection_id", collectionID) return nil, crud.ErrInvalidInput @@ -103,6 +103,29 @@ func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID in return result, nil } +func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID int64) ([]EndpointEntity, error) { + if err := crud.ValidateID(collectionID); err != nil { + log.Warn("endpoint list failed collection validation", "collection_id", collectionID) + return nil, crud.ErrInvalidInput + } + + log.Debug("listing endpoints", "collection_id", collectionID, "limit") + + endpoints, err := e.DB.ListEndpointsByCollection(context.Background(), collectionID) + if err != nil && err != sql.ErrNoRows { + log.Warn("error occured while fetching endpoints", "collection_id", collectionID) + return nil, err + } + + entities := make([]EndpointEntity, len(endpoints)) + for i, endpoint := range endpoints { + entities[i] = EndpointEntity{Endpoint: endpoint} + } + + log.Info("retrieved endpoints", "collection_id", collectionID, "count") + return entities, nil +} + func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData) (EndpointEntity, error) { if err := crud.ValidateID(data.CollectionID); err != nil { log.Warn("endpoint creation failed collection validation", "collection_id", data.CollectionID) diff --git a/internal/backend/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go index d8aa701..1355c5b 100644 --- a/internal/backend/endpoints/manager_test.go +++ b/internal/backend/endpoints/manager_test.go @@ -282,7 +282,7 @@ func TestListByCollection(t *testing.T) { } t.Run("Valid pagination", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 0) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 0) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -308,7 +308,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Second page", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 2) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 2) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -328,7 +328,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Last page", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 4) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 4) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -348,7 +348,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Invalid collection ID", func(t *testing.T) { - _, err := manager.ListByCollection(ctx, -1, 10, 0) + _, err := manager.ListByCollectionByPage(ctx, -1, 10, 0) if err != crud.ErrInvalidInput { t.Errorf("Expected ErrInvalidInput, got %v", err) } @@ -356,7 +356,7 @@ func TestListByCollection(t *testing.T) { t.Run("Empty collection", func(t *testing.T) { emptyCollectionID := testutils.CreateTestCollection(t, db, "Empty Collection") - result, err := manager.ListByCollection(ctx, emptyCollectionID, 10, 0) + result, err := manager.ListByCollectionByPage(ctx, emptyCollectionID, 10, 0) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 9681713..cf1e281 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -8,6 +8,8 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/log" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" "github.com/maniac-en/req/internal/tui/styles" @@ -53,9 +55,16 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, cmd) return a, tea.Batch(cmds...) - case messages.ChooseCollection: - a.focusedView = Endpoints - return a, tea.Batch(cmds...) + case messages.ChooseItem[optionsProvider.Option]: + switch msg.Source { + case "collections": + err := a.Views[Endpoints].SetState(msg.Item) + if err != nil { + log.Error(err.Error()) + } + a.focusedView = Endpoints + return a, tea.Batch(cmds...) + } case tea.KeyMsg: switch { case key.Matches(msg, keybinds.Keys.Quit): @@ -141,7 +150,7 @@ func NewAppModel(ctx *Context) AppModel { } model.Views = map[ViewName]views.ViewInterface{ Collections: views.NewCollectionsView(model.ctx.Collections, 1), - Endpoints: views.NewEndpointsView(2), + Endpoints: views.NewEndpointsView(model.ctx.Endpoints, 2), } return model } diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go index b21e4de..2bf2aa0 100644 --- a/internal/tui/components/OptionsProvider/component.go +++ b/internal/tui/components/OptionsProvider/component.go @@ -29,6 +29,7 @@ type OptionsProvider[T, U any] struct { focused focusedComp getItems func(context.Context) ([]T, error) itemMapper func([]T) []list.Item + source string } type Option struct { @@ -67,7 +68,12 @@ func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.C case key.Matches(msg, o.keys.DeleteItem): return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } case key.Matches(msg, o.keys.Choose): - return o, func() tea.Msg { return messages.ChooseCollection{} } + return o, func() tea.Msg { + return messages.ChooseItem[Option]{ + Item: o.GetSelected(), + Source: o.source, + } + } case key.Matches(msg, o.keys.EditItem): o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) o.input.SetInput(o.GetSelected().Name) @@ -188,8 +194,16 @@ func initList[T, U any](config *ListConfig[T, U]) list.Model { return list } -func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { +func (o *OptionsProvider[T, U]) SetGetItemsFunc(getItems func(context.Context) ([]T, error)) { + o.getItems = getItems + items, err := getItems(context.Background()) + if err != nil { + log.Error("error fetching items") + } + o.list.SetItems(o.itemMapper(items)) +} +func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { inputConfig := InputConfig{ CharLimit: 100, Placeholder: "Add A New Collection...", @@ -208,6 +222,6 @@ func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U getItems: config.GetItemsFunc, itemMapper: config.ItemMapper, keys: config.AdditionalKeymaps, - // onSelectAction: config.OnSelectAction, + source: config.Source, } } diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go index 361ee0e..552681a 100644 --- a/internal/tui/components/OptionsProvider/config.go +++ b/internal/tui/components/OptionsProvider/config.go @@ -2,6 +2,7 @@ package optionsProvider import ( "context" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -27,6 +28,7 @@ type ListConfig[T, U any] struct { ItemMapper func([]T) []list.Item GetItemsFunc func(context.Context) ([]T, error) + Source string // Style lipgloss.Style } diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 1dfa97d..b9b4506 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -12,6 +12,9 @@ type DeleteItem struct { ItemID int64 } -type ChooseCollection struct{} +type ChooseItem[T any] struct { + Item T + Source string +} type DeactivateView struct{} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index a774e2f..a6caac6 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -2,6 +2,7 @@ package views import ( "context" + "errors" "fmt" "github.com/charmbracelet/bubbles/help" @@ -68,6 +69,10 @@ func (c CollectionsView) View() string { return c.list.View() } +func (c CollectionsView) SetState(items ...any) error { + return errors.New("This view does not implement set state") +} + func (c CollectionsView) Order() int { return c.order } @@ -100,6 +105,7 @@ func NewCollectionsView(collManager *collections.CollectionsManager, order int) config.GetItemsFunc = collManager.List config.ItemMapper = itemMapper config.AdditionalKeymaps = keybinds + config.Source = "collections" return &CollectionsView{ list: optionsProvider.NewOptionsProvider(config), diff --git a/internal/tui/views/endpoints-view.go b/internal/tui/views/endpoints-view.go index 5c15070..53803e6 100644 --- a/internal/tui/views/endpoints-view.go +++ b/internal/tui/views/endpoints-view.go @@ -1,60 +1,124 @@ package views import ( + "context" + "errors" + "fmt" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/backend/database" + "github.com/maniac-en/req/internal/backend/endpoints" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" ) type EndpointsView struct { - height int - width int - order int + height int + collection optionsProvider.Option + width int + order int + list optionsProvider.OptionsProvider[endpoints.EndpointEntity, database.Endpoint] + manager *endpoints.EndpointsManager } -func (e EndpointsView) Init() tea.Cmd { +func (e *EndpointsView) Init() tea.Cmd { return nil } -func (e EndpointsView) Name() string { +func (e *EndpointsView) Name() string { return "Endpoints" } -func (e EndpointsView) Help() []key.Binding { +func (e *EndpointsView) Help() []key.Binding { return []key.Binding{} } -func (e EndpointsView) GetFooterSegment() string { - return "" +func (e *EndpointsView) GetFooterSegment() string { + return fmt.Sprintf("%s/", e.collection.Name) } -func (e EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { +func (e *EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: e.height = msg.Height e.width = msg.Width + e.list, cmd = e.list.Update(msg) + cmds = append(cmds, cmd) } - return e, nil + return e, tea.Batch(cmds...) } -func (e EndpointsView) View() string { - return lipgloss.NewStyle().Height(e.height).Width(e.width).Align(lipgloss.Center, lipgloss.Center).Render("Endpoints View!") +func (e *EndpointsView) View() string { + return e.list.View() } -func (e EndpointsView) OnFocus() { +func (e *EndpointsView) OnFocus() { + +} +func (e *EndpointsView) SetState(items ...any) error { + if len(items) == 1 { + if collection, ok := items[0].(optionsProvider.Option); ok { + e.collection = collection + epListFunc := func(ctx context.Context) ([]endpoints.EndpointEntity, error) { + return e.manager.ListByCollection(ctx, collection.ID) + } + e.list.SetGetItemsFunc(epListFunc) + return nil + } + } + return errors.New("Invalid inputs, this function takes 1 input of type optionsProvider.Options") } -func (e EndpointsView) OnBlur() { +func (e *EndpointsView) OnBlur() { } -func (e EndpointsView) Order() int { +func (e *EndpointsView) Order() int { return e.order } -func NewEndpointsView(order int) *EndpointsView { - return &EndpointsView{ +func itemMapperEp(items []endpoints.EndpointEntity) []list.Item { + opts := make([]list.Item, len(items)) + for i, item := range items { + newOpt := optionsProvider.Option{ + Name: item.GetName(), + Subtext: item.Method, + ID: item.GetID(), + } + opts[i] = newOpt + } + return opts +} + +func NewEndpointsView(epManager *endpoints.EndpointsManager, order int) *EndpointsView { + view := &EndpointsView{ order: order, + collection: optionsProvider.Option{ + Name: "", + Subtext: "", + ID: 0, + }, + manager: epManager, } + + keybinds := keybinds.NewListKeyMap() + config := defaultListConfig[endpoints.EndpointEntity, database.Endpoint](keybinds) + + epListFunc := func(ctx context.Context) ([]endpoints.EndpointEntity, error) { + return epManager.ListByCollection(ctx, view.collection.ID) + } + + config.GetItemsFunc = epListFunc + config.ItemMapper = itemMapperEp + config.AdditionalKeymaps = keybinds + config.Source = "collections" + + view.list = optionsProvider.NewOptionsProvider(config) + + return view } diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go index c6da38e..465b14d 100644 --- a/internal/tui/views/types.go +++ b/internal/tui/views/types.go @@ -13,6 +13,7 @@ type ViewInterface interface { Update(tea.Msg) (ViewInterface, tea.Cmd) View() string Order() int + SetState(...any) error OnFocus() OnBlur() } From d098bb7a4b096877117db8f2295aca11dd2616e1 Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Tue, 12 Aug 2025 22:50:31 +0530 Subject: [PATCH 17/27] changes: - new update func for ep for updating just names - changed the create function for eps to allow creation without url - completed ep view --- db/queries/endpoints.sql | 8 +++++ internal/backend/database/endpoints.sql.go | 32 ++++++++++++++++++ internal/backend/endpoints/manager.go | 39 +++++++++++++++++++--- internal/tui/views/endpoints-view.go | 18 +++++++++- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/db/queries/endpoints.sql b/db/queries/endpoints.sql index a4766e9..6e045dd 100644 --- a/db/queries/endpoints.sql +++ b/db/queries/endpoints.sql @@ -31,6 +31,14 @@ LIMIT ? OFFSET ?; SELECT COUNT(*) FROM endpoints WHERE collection_id = ?; +-- name: UpdateEndpointName :one +UPDATE endpoints +SET + name = ? +WHERE + id = ? +RETURNING *; + -- name: UpdateEndpoint :one UPDATE endpoints SET diff --git a/internal/backend/database/endpoints.sql.go b/internal/backend/database/endpoints.sql.go index 59c2925..7f38f90 100644 --- a/internal/backend/database/endpoints.sql.go +++ b/internal/backend/database/endpoints.sql.go @@ -241,3 +241,35 @@ func (q *Queries) UpdateEndpoint(ctx context.Context, arg UpdateEndpointParams) ) return i, err } + +const updateEndpointName = `-- name: UpdateEndpointName :one +UPDATE endpoints +SET + name = ? +WHERE + id = ? +RETURNING id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at +` + +type UpdateEndpointNameParams struct { + Name string `db:"name" json:"name"` + ID int64 `db:"id" json:"id"` +} + +func (q *Queries) UpdateEndpointName(ctx context.Context, arg UpdateEndpointNameParams) (Endpoint, error) { + row := q.db.QueryRowContext(ctx, updateEndpointName, arg.Name, arg.ID) + var i Endpoint + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.Name, + &i.Method, + &i.Url, + &i.Headers, + &i.QueryParams, + &i.RequestBody, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/backend/endpoints/manager.go b/internal/backend/endpoints/manager.go index 8a30a75..7cabdb3 100644 --- a/internal/backend/endpoints/manager.go +++ b/internal/backend/endpoints/manager.go @@ -135,8 +135,8 @@ func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData log.Warn("endpoint creation failed name validation", "name", data.Name) return EndpointEntity{}, crud.ErrInvalidInput } - if data.Method == "" || data.URL == "" { - log.Warn("endpoint creation failed - method and URL required", "method", data.Method, "url", data.URL) + if data.Method == "" { + log.Warn("endpoint creation failed - method required", "method", data.Method) return EndpointEntity{}, crud.ErrInvalidInput } @@ -174,6 +174,37 @@ func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData return EndpointEntity{Endpoint: endpoint}, nil } +func (e *EndpointsManager) UpdateEndpointName(ctx context.Context, id int64, name string) (EndpointEntity, error) { + if err := crud.ValidateID(id); err != nil { + log.Warn("endpoint update failed ID validation", "id", id) + return EndpointEntity{}, crud.ErrInvalidInput + } + + if err := crud.ValidateName(name); err != nil { + log.Warn("endpoint update failed name validation", "name", name) + return EndpointEntity{}, crud.ErrInvalidInput + } + + log.Debug("updating endpoint name", "id", id, "name", name) + + endpoint, err := e.DB.UpdateEndpointName(ctx, database.UpdateEndpointNameParams{ + Name: name, + ID: id, + }) + + if err != nil { + if err == sql.ErrNoRows { + log.Debug("endpoint not found for update", "id", id) + return EndpointEntity{}, crud.ErrNotFound + } + log.Error("failed to update endpoint", "id", id, "name", name, "error", err) + return EndpointEntity{}, err + } + + log.Info("updated endpoint", "id", endpoint.ID, "name", endpoint.Name) + return EndpointEntity{Endpoint: endpoint}, nil +} + func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data EndpointData) (EndpointEntity, error) { if err := crud.ValidateID(id); err != nil { log.Warn("endpoint update failed ID validation", "id", id) @@ -183,8 +214,8 @@ func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data En log.Warn("endpoint update failed name validation", "name", data.Name) return EndpointEntity{}, crud.ErrInvalidInput } - if data.Method == "" || data.URL == "" { - log.Warn("endpoint update failed - method and URL required", "method", data.Method, "url", data.URL) + if data.Method == "" { + log.Warn("endpoint update failed - method required", "method", data.Method) return EndpointEntity{}, crud.ErrInvalidInput } diff --git a/internal/tui/views/endpoints-view.go b/internal/tui/views/endpoints-view.go index 53803e6..dec2343 100644 --- a/internal/tui/views/endpoints-view.go +++ b/internal/tui/views/endpoints-view.go @@ -12,6 +12,7 @@ import ( "github.com/maniac-en/req/internal/backend/endpoints" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" ) type EndpointsView struct { @@ -32,7 +33,7 @@ func (e *EndpointsView) Name() string { } func (e *EndpointsView) Help() []key.Binding { - return []key.Binding{} + return e.list.Help() } func (e *EndpointsView) GetFooterSegment() string { @@ -48,7 +49,22 @@ func (e *EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { e.width = msg.Width e.list, cmd = e.list.Update(msg) cmds = append(cmds, cmd) + case messages.ItemAdded: + e.manager.CreateEndpoint(context.Background(), endpoints.EndpointData{ + CollectionID: e.collection.ID, + Name: msg.Item, + Method: "GET", + }) + case messages.ItemEdited: + e.manager.UpdateEndpointName(context.Background(), msg.ItemID, msg.Item) + case messages.DeleteItem: + e.manager.Delete(context.Background(), msg.ItemID) + e.list.RefreshItems() } + + e.list, cmd = e.list.Update(msg) + cmds = append(cmds, cmd) + return e, tea.Batch(cmds...) } From ce6a7790fa25ed06673c9671e2a2d66c6ff50ecc Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Tue, 12 Aug 2025 22:56:23 +0530 Subject: [PATCH 18/27] minor style fix --- internal/tui/styles/app-styles.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index bfb31c3..8519353 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -5,7 +5,7 @@ import ( ) var ( - footerNameStyle = lipgloss.NewStyle().Bold(true) + footerNameStyle = lipgloss.NewStyle().Bold(true).Background(footerNameBG) footerNameBGStyle = lipgloss.NewStyle().Background(footerNameBG).Padding(0, 3, 0) FooterSegmentStyle = lipgloss.NewStyle().Background(footerSegmentBG).PaddingLeft(2).Foreground(footerSegmentFG) FooterVersionStyle = lipgloss.NewStyle().Background(footerSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(footerSegmentFG) From 00dc5d7efbf623b73cc32d7d581948d9737a0bba Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 17:00:06 +0530 Subject: [PATCH 19/27] fix: remove EndpointCount from CollectionEntity - Remove EndpointCount field from core CollectionEntity - Use simple GetCollections query without JOIN - Add GetEndpointCountsByCollections query - Update itemMapper to fetch counts only for display --- db/queries/collections.sql | 12 +--- db/queries/endpoints.sql | 5 ++ internal/backend/collections/manager.go | 20 +++---- internal/backend/collections/models.go | 4 -- internal/backend/database/collections.sql.go | 27 ++------- internal/backend/database/endpoints.sql.go | 34 +++++++++++ internal/backend/endpoints/manager.go | 9 +++ internal/tui/app/model.go | 2 +- internal/tui/views/collections-view.go | 60 ++++++++++++++------ 9 files changed, 105 insertions(+), 68 deletions(-) diff --git a/db/queries/collections.sql b/db/queries/collections.sql index 8ddc2d4..4f7eedc 100644 --- a/db/queries/collections.sql +++ b/db/queries/collections.sql @@ -7,16 +7,8 @@ ORDER BY created_at DESC LIMIT ? OFFSET ?; -- name: GetCollections :many -SELECT - c.id, - c.name, - c.created_at, - c.updated_at, - COUNT(e.id) AS endpoint_count -FROM collections c -LEFT JOIN endpoints e ON e.collection_id = c.id -GROUP BY c.id, c.name, c.created_at, c.updated_at -ORDER BY c.created_at DESC; +SELECT * FROM collections +ORDER BY created_at DESC; -- name: CountCollections :one SELECT COUNT(*) FROM collections; diff --git a/db/queries/endpoints.sql b/db/queries/endpoints.sql index 6e045dd..d672be7 100644 --- a/db/queries/endpoints.sql +++ b/db/queries/endpoints.sql @@ -31,6 +31,11 @@ LIMIT ? OFFSET ?; SELECT COUNT(*) FROM endpoints WHERE collection_id = ?; +-- name: GetEndpointCountsByCollections :many +SELECT collection_id, COUNT(*) as count +FROM endpoints +GROUP BY collection_id; + -- name: UpdateEndpointName :one UPDATE endpoints SET diff --git a/internal/backend/collections/manager.go b/internal/backend/collections/manager.go index 69326d4..d9f07c4 100644 --- a/internal/backend/collections/manager.go +++ b/internal/backend/collections/manager.go @@ -96,23 +96,17 @@ func (c *CollectionsManager) Delete(ctx context.Context, id int64) error { } func (c *CollectionsManager) List(ctx context.Context) ([]CollectionEntity, error) { - log.Debug("listing all collections without pagination") collections, err := c.DB.GetCollections(ctx) - collectionsEntity := []CollectionEntity{} - for _, collection := range collections { - collectionsEntity = append(collectionsEntity, CollectionEntity{Collection: database.Collection{ - ID: collection.ID, - Name: collection.Name, - CreatedAt: collection.CreatedAt, - UpdatedAt: collection.UpdatedAt, - }, - EndpointCount: int(collection.EndpointCount), - }) - } if err != nil { return nil, err } - return collectionsEntity, nil + + entities := make([]CollectionEntity, len(collections)) + for i, collection := range collections { + entities[i] = CollectionEntity{Collection: collection} + } + + return entities, nil } func (c *CollectionsManager) ListPaginated(ctx context.Context, limit, offset int) (*PaginatedCollections, error) { diff --git a/internal/backend/collections/models.go b/internal/backend/collections/models.go index cdb60e6..72778cc 100644 --- a/internal/backend/collections/models.go +++ b/internal/backend/collections/models.go @@ -9,7 +9,6 @@ import ( type CollectionEntity struct { database.Collection - EndpointCount int } func (c CollectionEntity) GetID() int64 { @@ -20,9 +19,6 @@ func (c CollectionEntity) GetName() string { return c.Name } -func (c CollectionEntity) GetEnpointCount() int { - return c.EndpointCount -} func (c CollectionEntity) GetCreatedAt() time.Time { return crud.ParseTimestamp(c.CreatedAt) diff --git a/internal/backend/database/collections.sql.go b/internal/backend/database/collections.sql.go index 3198766..799c0c3 100644 --- a/internal/backend/database/collections.sql.go +++ b/internal/backend/database/collections.sql.go @@ -64,41 +64,24 @@ func (q *Queries) GetCollection(ctx context.Context, id int64) (Collection, erro } const getCollections = `-- name: GetCollections :many -SELECT - c.id, - c.name, - c.created_at, - c.updated_at, - COUNT(e.id) AS endpoint_count -FROM collections c -LEFT JOIN endpoints e ON e.collection_id = c.id -GROUP BY c.id, c.name, c.created_at, c.updated_at -ORDER BY c.created_at DESC +SELECT id, name, created_at, updated_at FROM collections +ORDER BY created_at DESC ` -type GetCollectionsRow struct { - ID int64 `db:"id" json:"id"` - Name string `db:"name" json:"name"` - CreatedAt string `db:"created_at" json:"created_at"` - UpdatedAt string `db:"updated_at" json:"updated_at"` - EndpointCount int64 `db:"endpoint_count" json:"endpoint_count"` -} - -func (q *Queries) GetCollections(ctx context.Context) ([]GetCollectionsRow, error) { +func (q *Queries) GetCollections(ctx context.Context) ([]Collection, error) { rows, err := q.db.QueryContext(ctx, getCollections) if err != nil { return nil, err } defer rows.Close() - var items []GetCollectionsRow + var items []Collection for rows.Next() { - var i GetCollectionsRow + var i Collection if err := rows.Scan( &i.ID, &i.Name, &i.CreatedAt, &i.UpdatedAt, - &i.EndpointCount, ); err != nil { return nil, err } diff --git a/internal/backend/database/endpoints.sql.go b/internal/backend/database/endpoints.sql.go index 7f38f90..0b3ebd3 100644 --- a/internal/backend/database/endpoints.sql.go +++ b/internal/backend/database/endpoints.sql.go @@ -105,6 +105,40 @@ func (q *Queries) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { return i, err } +const getEndpointCountsByCollections = `-- name: GetEndpointCountsByCollections :many +SELECT collection_id, COUNT(*) as count +FROM endpoints +GROUP BY collection_id +` + +type GetEndpointCountsByCollectionsRow struct { + CollectionID int64 `db:"collection_id" json:"collection_id"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) GetEndpointCountsByCollections(ctx context.Context) ([]GetEndpointCountsByCollectionsRow, error) { + rows, err := q.db.QueryContext(ctx, getEndpointCountsByCollections) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEndpointCountsByCollectionsRow + for rows.Next() { + var i GetEndpointCountsByCollectionsRow + if err := rows.Scan(&i.CollectionID, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listEndpointsByCollection = `-- name: ListEndpointsByCollection :many SELECT id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at FROM endpoints WHERE collection_id = ? diff --git a/internal/backend/endpoints/manager.go b/internal/backend/endpoints/manager.go index 7cabdb3..7c4470a 100644 --- a/internal/backend/endpoints/manager.go +++ b/internal/backend/endpoints/manager.go @@ -256,3 +256,12 @@ func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data En log.Info("updated endpoint", "id", endpoint.ID, "name", endpoint.Name) return EndpointEntity{Endpoint: endpoint}, nil } + +func (e *EndpointsManager) GetCountsByCollections(ctx context.Context) ([]database.GetEndpointCountsByCollectionsRow, error) { + counts, err := e.DB.GetEndpointCountsByCollections(ctx) + if err != nil { + log.Error("failed to get endpoint counts", "error", err) + return nil, err + } + return counts, nil +} diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index cf1e281..367e067 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -149,7 +149,7 @@ func NewAppModel(ctx *Context) AppModel { keys: appKeybinds, } model.Views = map[ViewName]views.ViewInterface{ - Collections: views.NewCollectionsView(model.ctx.Collections, 1), + Collections: views.NewCollectionsView(model.ctx.Collections, model.ctx.Endpoints, 1), Endpoints: views.NewEndpointsView(model.ctx.Endpoints, 2), } return model diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index a6caac6..0185bbc 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -10,19 +10,21 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" + "github.com/maniac-en/req/internal/backend/endpoints" optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" "github.com/maniac-en/req/internal/tui/keybinds" "github.com/maniac-en/req/internal/tui/messages" ) type CollectionsView struct { - width int - height int - list optionsProvider.OptionsProvider[collections.CollectionEntity, string] - manager *collections.CollectionsManager - help help.Model - keys *keybinds.ListKeyMap - order int + width int + height int + list optionsProvider.OptionsProvider[collections.CollectionEntity, string] + manager *collections.CollectionsManager + endpointsManager *endpoints.EndpointsManager + help help.Model + keys *keybinds.ListKeyMap + order int } func (c CollectionsView) Init() tea.Cmd { @@ -85,33 +87,55 @@ func (c CollectionsView) OnBlur() { } -func itemMapper(items []collections.CollectionEntity) []list.Item { +func itemMapper(items []collections.CollectionEntity, endpointsManager *endpoints.EndpointsManager) []list.Item { opts := make([]list.Item, len(items)) + + counts, err := endpointsManager.GetCountsByCollections(context.Background()) + if err != nil { + for i, item := range items { + opts[i] = optionsProvider.Option{ + Name: item.GetName(), + Subtext: "0 endpoints", + ID: item.GetID(), + } + } + return opts + } + + countMap := make(map[int64]int) + for _, count := range counts { + countMap[count.CollectionID] = int(count.Count) + } + for i, item := range items { - newOpt := optionsProvider.Option{ + count := countMap[item.GetID()] + opts[i] = optionsProvider.Option{ Name: item.GetName(), - Subtext: fmt.Sprintf("%d endpoints", item.GetEnpointCount()), + Subtext: fmt.Sprintf("%d endpoints", count), ID: item.GetID(), } - opts[i] = newOpt } + return opts } -func NewCollectionsView(collManager *collections.CollectionsManager, order int) *CollectionsView { +func NewCollectionsView(collManager *collections.CollectionsManager, endpointsManager *endpoints.EndpointsManager, order int) *CollectionsView { keybinds := keybinds.NewListKeyMap() config := defaultListConfig[collections.CollectionEntity, string](keybinds) config.GetItemsFunc = collManager.List - config.ItemMapper = itemMapper + config.ItemMapper = func(items []collections.CollectionEntity) []list.Item { + return itemMapper(items, endpointsManager) + } config.AdditionalKeymaps = keybinds config.Source = "collections" return &CollectionsView{ - list: optionsProvider.NewOptionsProvider(config), - manager: collManager, - help: help.New(), - keys: keybinds, - order: order, + list: optionsProvider.NewOptionsProvider(config), + manager: collManager, + endpointsManager: endpointsManager, + help: help.New(), + keys: keybinds, + order: order, } } From 1af51e5b6ff66b62b95a7774cbb36364d8e92175 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 17:37:01 +0530 Subject: [PATCH 20/27] refactor: use message-driven navigation - Add NavigateToView message type - Views emit navigation messages, app handles routing --- internal/tui/app/model.go | 23 +++++++++++++++++++---- internal/tui/messages/messages.go | 5 +++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 367e067..25256ea 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -58,13 +58,27 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ChooseItem[optionsProvider.Option]: switch msg.Source { case "collections": - err := a.Views[Endpoints].SetState(msg.Item) + return a, func() tea.Msg { + return messages.NavigateToView{ + ViewName: string(Endpoints), + Data: msg.Item, + } + } + } + case messages.NavigateToView: + a.Views[a.focusedView].OnBlur() + + if msg.Data != nil { + err := a.Views[ViewName(msg.ViewName)].SetState(msg.Data) if err != nil { - log.Error(err.Error()) + log.Error("failed to set view state during navigation", "target_view", msg.ViewName, "error", err) + return a, nil } - a.focusedView = Endpoints - return a, tea.Batch(cmds...) } + + a.focusedView = ViewName(msg.ViewName) + a.Views[a.focusedView].OnFocus() + return a, nil case tea.KeyMsg: switch { case key.Matches(msg, keybinds.Keys.Quit): @@ -154,3 +168,4 @@ func NewAppModel(ctx *Context) AppModel { } return model } + diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index b9b4506..db0b4fb 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -18,3 +18,8 @@ type ChooseItem[T any] struct { } type DeactivateView struct{} + +type NavigateToView struct { + ViewName string + Data interface{} +} From 84ab6c5c28e888b00c8525c3ec126a2c13f28907 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 18:16:50 +0530 Subject: [PATCH 21/27] feat: Prototype message-driven flow for ItemAdded ItemAdded now uses commands, improving internal consistency and laying the groundwork for better error handling/UI updates. --- internal/tui/app/model.go | 12 ++++++++++++ internal/tui/messages/messages.go | 6 ++++++ internal/tui/styles/app-styles.go | 1 + internal/tui/views/collections-view.go | 13 ++++++++++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 25256ea..6adcc3a 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -36,6 +36,7 @@ type AppModel struct { focusedView ViewName keys []key.Binding help help.Model + errorMsg string } func (a AppModel) Init() tea.Cmd { @@ -79,7 +80,12 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.focusedView = ViewName(msg.ViewName) a.Views[a.focusedView].OnFocus() return a, nil + case messages.ShowError: + log.Error("user operation failed", "error", msg.Message) + a.errorMsg = msg.Message + return a, nil case tea.KeyMsg: + a.errorMsg = "" switch { case key.Matches(msg, keybinds.Keys.Quit): return a, tea.Quit @@ -97,6 +103,12 @@ func (a AppModel) View() string { header := a.Header() view := a.Views[a.focusedView].View() help := a.Help() + + if a.errorMsg != "" { + errorBar := styles.ErrorBarStyle.Width(a.width).Render("Error: " + a.errorMsg) + return lipgloss.JoinVertical(lipgloss.Top, header, view, errorBar, help, footer) + } + return lipgloss.JoinVertical(lipgloss.Top, header, view, help, footer) } diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index db0b4fb..a3192b6 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -23,3 +23,9 @@ type NavigateToView struct { ViewName string Data interface{} } + +type RefreshItemsList struct{} + +type ShowError struct { + Message string +} diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go index 8519353..365ef5d 100644 --- a/internal/tui/styles/app-styles.go +++ b/internal/tui/styles/app-styles.go @@ -13,4 +13,5 @@ var ( TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) HelpStyle = lipgloss.NewStyle().Padding(1, 0, 1, 2) AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(helpFG) + ErrorBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#FF0000")).Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1) ) diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go index 0185bbc..e6f08a9 100644 --- a/internal/tui/views/collections-view.go +++ b/internal/tui/views/collections-view.go @@ -53,7 +53,18 @@ func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { c.list, cmd = c.list.Update(msg) cmds = append(cmds, cmd) case messages.ItemAdded: - c.manager.Create(context.Background(), msg.Item) + _, err := c.manager.Create(context.Background(), msg.Item) + if err != nil { + return c, func() tea.Msg { + return messages.ShowError{Message: err.Error()} + } + } + return c, func() tea.Msg { + return messages.RefreshItemsList{} + } + case messages.RefreshItemsList: + c.list.RefreshItems() + return c, nil case messages.ItemEdited: c.manager.Update(context.Background(), msg.ItemID, msg.Item) case messages.DeleteItem: From b0311e1f09b0c0a5c9c60b44792f32369fbd74f1 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 18:27:46 +0530 Subject: [PATCH 22/27] chore: bump version to v0.1.0-alpha.3 - Reflects architectural improvements and backend cleanup - Message-driven navigation and CRUD operations - Performance improvements with efficient batch queries - Enhanced error handling prototype --- internal/tui/app/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 6adcc3a..bcf7ad7 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -159,7 +159,7 @@ func (a AppModel) Header() string { func (a AppModel) Footer() string { name := styles.ApplyGradientToFooter("REQ") footerText := styles.FooterSegmentStyle.Render(a.Views[a.focusedView].GetFooterSegment()) - version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.2") + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.3") return lipgloss.JoinHorizontal(lipgloss.Left, name, footerText, version) } From c5e663ea53b6b219ed8d4be7c15af6a39427e0e0 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 19:36:49 +0530 Subject: [PATCH 23/27] chore: add navigation with q/esc for back navigation It won't work if you're on "Collections" view because there's no going back from there. I've kept this separate from `Ctrl-c` quit as to have different key-stroke flows for going back/forth vs quitting the app. --- internal/tui/app/model.go | 21 +++++++++++++++++++-- internal/tui/keybinds/keys.go | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index bcf7ad7..becc3b8 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -89,6 +89,15 @@ func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keybinds.Keys.Quit): return a, tea.Quit + case key.Matches(msg, keybinds.Keys.Back): + if a.focusedView == Endpoints { + return a, func() tea.Msg { + return messages.NavigateToView{ + ViewName: string(Collections), + Data: nil, + } + } + } } } @@ -114,9 +123,17 @@ func (a AppModel) View() string { func (a AppModel) Help() string { viewHelp := a.Views[a.focusedView].Help() - appHelp := append(viewHelp, a.keys...) + + var appHelp []key.Binding + appHelp = append(appHelp, a.keys...) + + if a.focusedView == Endpoints { + appHelp = append(appHelp, keybinds.Keys.Back) + } + + allHelp := append(viewHelp, appHelp...) helpStruct := keybinds.Help{ - Keys: appHelp, + Keys: allHelp, } return styles.HelpStyle.Render(a.help.View(helpStruct)) } diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go index b4bb527..d04f267 100644 --- a/internal/tui/keybinds/keys.go +++ b/internal/tui/keybinds/keys.go @@ -24,8 +24,8 @@ type Keymaps struct { var Keys = Keymaps{ Back: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "back"), + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "back"), ), Up: key.NewBinding( key.WithKeys("up", "k"), From 92a274f020d8947a02ef7df46c05f72cc1af3510 Mon Sep 17 00:00:00 2001 From: Shivam Mehta Date: Sun, 17 Aug 2025 19:44:10 +0530 Subject: [PATCH 24/27] Revert "chore: bump version to v0.1.0-alpha.3" This reverts commit b0311e1f09b0c0a5c9c60b44792f32369fbd74f1. --- internal/tui/app/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index becc3b8..ca103f0 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -176,7 +176,7 @@ func (a AppModel) Header() string { func (a AppModel) Footer() string { name := styles.ApplyGradientToFooter("REQ") footerText := styles.FooterSegmentStyle.Render(a.Views[a.focusedView].GetFooterSegment()) - version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.3") + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.2") return lipgloss.JoinHorizontal(lipgloss.Left, name, footerText, version) } From d1a09510cbd1e6e2ef037d8bd15f3d541215cd8a Mon Sep 17 00:00:00 2001 From: yashranjan1 Date: Sat, 23 Aug 2025 12:42:18 +0530 Subject: [PATCH 25/27] fix:test update for the empty URL tests for endpoints manager --- internal/backend/endpoints/manager_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/backend/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go index 1355c5b..2ee93cf 100644 --- a/internal/backend/endpoints/manager_test.go +++ b/internal/backend/endpoints/manager_test.go @@ -178,8 +178,8 @@ func TestCreateEndpoint(t *testing.T) { } _, err := manager.CreateEndpoint(ctx, data) - if err != crud.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + if err != nil { + t.Errorf("Create Endpoint without URL failed: %v", err) } }) } From 8a3989a2f815d7fc2bce453ce75117e34611c3fb Mon Sep 17 00:00:00 2001 From: Yash <113207367+yashranjan1@users.noreply.github.com> Date: Sun, 24 Aug 2025 18:28:28 +0530 Subject: [PATCH 26/27] fix: made manager tests have better error messages --- internal/backend/endpoints/manager_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/backend/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go index 2ee93cf..4b01f1a 100644 --- a/internal/backend/endpoints/manager_test.go +++ b/internal/backend/endpoints/manager_test.go @@ -177,9 +177,12 @@ func TestCreateEndpoint(t *testing.T) { URL: "", } - _, err := manager.CreateEndpoint(ctx, data) + endpoint, err := manager.CreateEndpoint(ctx, data) if err != nil { - t.Errorf("Create Endpoint without URL failed: %v", err) + t.Errorf("Expected empty URL to be allowed, got error: %v", err) + } + if endpoint.URL != "" { + t.Errorf("Expected empty URL to be preserved, got %s", endpoint.URL) } }) } From ad0e1ed625268d4493a590dc865be102d071925e Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 24 Aug 2025 18:34:16 +0530 Subject: [PATCH 27/27] fix: mistake --- internal/backend/endpoints/manager_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/backend/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go index 4b01f1a..303990b 100644 --- a/internal/backend/endpoints/manager_test.go +++ b/internal/backend/endpoints/manager_test.go @@ -179,10 +179,10 @@ func TestCreateEndpoint(t *testing.T) { endpoint, err := manager.CreateEndpoint(ctx, data) if err != nil { - t.Errorf("Expected empty URL to be allowed, got error: %v", err) + t.Errorf("Expected empty URL to be allowed, got error: %v", err) } - if endpoint.URL != "" { - t.Errorf("Expected empty URL to be preserved, got %s", endpoint.URL) + if endpoint.Url != "" { + t.Errorf("Expected empty URL to be preserved, got %s", endpoint.Url) } }) }