diff --git a/internal/backend/demo/dummy_data.go b/internal/backend/demo/dummy_data.go new file mode 100644 index 0000000..38aa02f --- /dev/null +++ b/internal/backend/demo/dummy_data.go @@ -0,0 +1,221 @@ +package demo + +import ( + "context" + + "github.com/maniac-en/req/internal/backend/collections" + "github.com/maniac-en/req/internal/backend/endpoints" + "github.com/maniac-en/req/internal/log" +) + +type DemoGenerator struct { + collectionsManager *collections.CollectionsManager + endpointsManager *endpoints.EndpointsManager +} + +func NewDemoGenerator(collectionsManager *collections.CollectionsManager, endpointsManager *endpoints.EndpointsManager) *DemoGenerator { + return &DemoGenerator{ + collectionsManager: collectionsManager, + endpointsManager: endpointsManager, + } +} + +func (d *DemoGenerator) PopulateDummyData(ctx context.Context) (bool, error) { + log.Info("populating dummy data for demo") + + // Check if we already have collections + result, err := d.collectionsManager.ListPaginated(ctx, 1, 0) + if err != nil { + log.Error("failed to check existing collections", "error", err) + return false, err + } + + if len(result.Collections) > 0 { + log.Debug("dummy data already exists, skipping population", "collections_count", len(result.Collections)) + return false, nil + } + + // Create demo collections and endpoints + if err := d.createJSONPlaceholderCollection(ctx); err != nil { + return false, err + } + + if err := d.createReqresCollection(ctx); err != nil { + return false, err + } + + if err := d.createHTTPBinCollection(ctx); err != nil { + return false, err + } + + log.Info("dummy data populated successfully") + return true, nil +} + +func (d *DemoGenerator) createJSONPlaceholderCollection(ctx context.Context) error { + collection, err := d.collectionsManager.Create(ctx, "JSONPlaceholder API") + if err != nil { + log.Error("failed to create JSONPlaceholder collection", "error", err) + return err + } + + endpoints := []endpoints.EndpointData{ + { + CollectionID: collection.ID, + Name: "Get All Posts", + Method: "GET", + URL: "https://jsonplaceholder.typicode.com/posts", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Get Single Post", + Method: "GET", + URL: "https://jsonplaceholder.typicode.com/posts/1", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Create Post", + Method: "POST", + URL: "https://jsonplaceholder.typicode.com/posts", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: `{"title": "My New Post", "body": "This is the content of my new post", "userId": 1}`, + }, + { + CollectionID: collection.ID, + Name: "Update Post", + Method: "PUT", + URL: "https://jsonplaceholder.typicode.com/posts/1", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: `{"id": 1, "title": "Updated Post", "body": "This post has been updated", "userId": 1}`, + }, + { + CollectionID: collection.ID, + Name: "Delete Post", + Method: "DELETE", + URL: "https://jsonplaceholder.typicode.com/posts/1", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + } + + return d.createEndpoints(ctx, endpoints) +} + +func (d *DemoGenerator) createReqresCollection(ctx context.Context) error { + collection, err := d.collectionsManager.Create(ctx, "ReqRes API") + if err != nil { + log.Error("failed to create ReqRes collection", "error", err) + return err + } + + endpoints := []endpoints.EndpointData{ + { + CollectionID: collection.ID, + Name: "List Users", + Method: "GET", + URL: "https://reqres.in/api/users", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{"page": "2"}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Single User", + Method: "GET", + URL: "https://reqres.in/api/users/2", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Create User", + Method: "POST", + URL: "https://reqres.in/api/users", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: `{"name": "morpheus", "job": "leader"}`, + }, + { + CollectionID: collection.ID, + Name: "Login", + Method: "POST", + URL: "https://reqres.in/api/login", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: `{"email": "eve.holt@reqres.in", "password": "cityslicka"}`, + }, + } + + return d.createEndpoints(ctx, endpoints) +} + +func (d *DemoGenerator) createHTTPBinCollection(ctx context.Context) error { + collection, err := d.collectionsManager.Create(ctx, "HTTPBin Testing") + if err != nil { + log.Error("failed to create HTTPBin collection", "error", err) + return err + } + + endpoints := []endpoints.EndpointData{ + { + CollectionID: collection.ID, + Name: "Test GET", + Method: "GET", + URL: "https://httpbin.org/get", + Headers: `{"User-Agent": "Req-Terminal-Client/1.0"}`, + QueryParams: map[string]string{"test": "value", "demo": "true"}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Test POST JSON", + Method: "POST", + URL: "https://httpbin.org/post", + Headers: `{"Content-Type": "application/json", "User-Agent": "Req-Terminal-Client/1.0"}`, + QueryParams: map[string]string{}, + RequestBody: `{"message": "Hello from Req!", "timestamp": "2024-01-15T10:30:00Z", "data": {"key": "value"}}`, + }, + { + CollectionID: collection.ID, + Name: "Test Headers", + Method: "GET", + URL: "https://httpbin.org/headers", + Headers: `{"Authorization": "Bearer demo-token", "X-Custom-Header": "req-demo"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + { + CollectionID: collection.ID, + Name: "Test Status Codes", + Method: "GET", + URL: "https://httpbin.org/status/200", + Headers: `{"Content-Type": "application/json"}`, + QueryParams: map[string]string{}, + RequestBody: "", + }, + } + + return d.createEndpoints(ctx, endpoints) +} + +func (d *DemoGenerator) createEndpoints(ctx context.Context, endpointData []endpoints.EndpointData) error { + for _, data := range endpointData { + _, err := d.endpointsManager.CreateEndpoint(ctx, data) + if err != nil { + log.Error("failed to create endpoint", "name", data.Name, "error", err) + return err + } + log.Debug("created demo endpoint", "name", data.Name, "method", data.Method, "url", data.URL) + } + return nil +} \ No newline at end of file diff --git a/internal/tui/app/context.go b/internal/tui/app/context.go index f14a22f..6e2cbb8 100644 --- a/internal/tui/app/context.go +++ b/internal/tui/app/context.go @@ -8,10 +8,11 @@ import ( ) type Context struct { - Collections *collections.CollectionsManager - Endpoints *endpoints.EndpointsManager - HTTP *http.HTTPManager - History *history.HistoryManager + Collections *collections.CollectionsManager + Endpoints *endpoints.EndpointsManager + HTTP *http.HTTPManager + History *history.HistoryManager + DummyDataCreated bool } func NewContext( @@ -21,9 +22,14 @@ func NewContext( history *history.HistoryManager, ) *Context { return &Context{ - Collections: collections, - Endpoints: endpoints, - HTTP: httpManager, - History: history, + Collections: collections, + Endpoints: endpoints, + HTTP: httpManager, + History: history, + DummyDataCreated: false, } } + +func (c *Context) SetDummyDataCreated(created bool) { + c.DummyDataCreated = created +} diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index e803f68..bcb82ac 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -30,10 +30,15 @@ type Model struct { } func NewModel(ctx *Context) Model { + collectionsView := views.NewCollectionsView(ctx.Collections) + if ctx.DummyDataCreated { + collectionsView.SetDummyDataNotification(true) + } + m := Model{ ctx: ctx, mode: CollectionsViewMode, - collectionsView: views.NewCollectionsView(ctx.Collections), + collectionsView: collectionsView, addCollectionView: views.NewAddCollectionView(ctx.Collections), } return m @@ -74,9 +79,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedIndex = m.collectionsView.GetSelectedIndex() m.mode = SelectedCollectionViewMode if m.width > 0 && m.height > 0 { - m.selectedCollectionView = views.NewSelectedCollectionViewWithSize(m.ctx.Endpoints, *selectedItem, m.width, m.height) + m.selectedCollectionView = views.NewSelectedCollectionViewWithSize(m.ctx.Endpoints, m.ctx.HTTP, *selectedItem, m.width, m.height) } else { - m.selectedCollectionView = views.NewSelectedCollectionView(m.ctx.Endpoints, *selectedItem) + m.selectedCollectionView = views.NewSelectedCollectionView(m.ctx.Endpoints, m.ctx.HTTP, *selectedItem) } return m, m.selectedCollectionView.Init() } else { @@ -117,6 +122,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -126,6 +134,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) + } } m.collectionsView.SetSelectedIndex(m.selectedIndex) return m, m.collectionsView.Init() diff --git a/internal/tui/components/keyvalue_editor.go b/internal/tui/components/keyvalue_editor.go new file mode 100644 index 0000000..5dd3692 --- /dev/null +++ b/internal/tui/components/keyvalue_editor.go @@ -0,0 +1,260 @@ +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 +} \ No newline at end of file diff --git a/internal/tui/components/layout.go b/internal/tui/components/layout.go index 756630f..a191e07 100644 --- a/internal/tui/components/layout.go +++ b/internal/tui/components/layout.go @@ -19,14 +19,40 @@ func (l *Layout) SetSize(width, height int) { 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 } @@ -34,10 +60,18 @@ func (l Layout) FullView(title, content, instructions string) string { windowHeight = 15 } - innerWidth := windowWidth - 4 + // Calculate inner content dimensions (accounting for border) + innerWidth := windowWidth - 4 // 2 chars for border + padding innerHeight := windowHeight - 4 - header := styles.WindowHeaderStyle.Copy(). + + // 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) @@ -47,34 +81,51 @@ func (l Layout) FullView(title, content, instructions string) string { contentHeight = 1 } - contentArea := styles.WindowContentStyle.Copy(). + 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, ) - borderedWindow := styles.WindowBorderStyle.Copy(). + // 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 := styles.AppBrandingStyle.Copy(). + 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) - footer := styles.WindowFooterStyle.Copy(). + // 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 + windowPlacementHeight := l.height - brandingHeight - footerHeight - 4 // Extra padding centeredWindow := lipgloss.Place( l.width, windowPlacementHeight, @@ -82,13 +133,14 @@ func (l Layout) FullView(title, content, instructions string) string { 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/text_input.go b/internal/tui/components/text_input.go index 074501b..4049b1f 100644 --- a/internal/tui/components/text_input.go +++ b/internal/tui/components/text_input.go @@ -17,7 +17,7 @@ func NewTextInput(label, placeholder string) TextInput { ti := textinput.New() ti.Placeholder = placeholder ti.Focus() - ti.CharLimit = 100 + ti.CharLimit = 5000 // Allow long content like JSON ti.Width = 50 return TextInput{ @@ -37,7 +37,20 @@ func (t TextInput) Value() string { func (t *TextInput) SetWidth(width int) { t.width = width - t.textInput.Width = width - len(t.label) - 4 // Account for label and spacing + // 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() { @@ -65,12 +78,30 @@ func (t TextInput) Update(msg tea.Msg) (TextInput, tea.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+":"), " ", - t.textInput.View(), + inputContainer.Render(t.textInput.View()), ) } diff --git a/internal/tui/components/textarea.go b/internal/tui/components/textarea.go new file mode 100644 index 0000000..a114892 --- /dev/null +++ b/internal/tui/components/textarea.go @@ -0,0 +1,313 @@ +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/layout.go b/internal/tui/styles/layout.go index 277ca3c..8f933ba 100644 --- a/internal/tui/styles/layout.go +++ b/internal/tui/styles/layout.go @@ -3,34 +3,21 @@ package styles import "github.com/charmbracelet/lipgloss" var ( - // Window and layout styles - WindowHeaderStyle = lipgloss.NewStyle(). - Padding(1, 2). - Background(Primary). - Foreground(TextPrimary). - Bold(true). - Align(lipgloss.Center) - - WindowContentStyle = lipgloss.NewStyle(). - Padding(1, 2) - - WindowBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("15")) // White border - - AppBrandingStyle = lipgloss.NewStyle(). - Align(lipgloss.Center). - Foreground(lipgloss.Color("230")). // Soft cream - Bold(true). - Padding(1, 4). - Margin(1, 0) - - WindowFooterStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(TextSecondary). - Align(lipgloss.Center) - - // List and item styles + 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) diff --git a/internal/tui/views/add_collection.go b/internal/tui/views/add_collection.go index efba739..8d33600 100644 --- a/internal/tui/views/add_collection.go +++ b/internal/tui/views/add_collection.go @@ -45,7 +45,7 @@ func (v AddCollectionView) Update(msg tea.Msg) (AddCollectionView, tea.Cmd) { v.width = msg.Width v.height = msg.Height v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-4, v.height-8) + v.form.SetSize(v.width-50, v.height-8) case tea.KeyMsg: if v.submitting { diff --git a/internal/tui/views/collections.go b/internal/tui/views/collections.go index 1040e9c..c5a8576 100644 --- a/internal/tui/views/collections.go +++ b/internal/tui/views/collections.go @@ -5,9 +5,11 @@ import ( "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 { @@ -18,6 +20,7 @@ type CollectionsView struct { height int initialized bool selectedIndex int + showDummyDataNotif bool currentPage int pageSize int @@ -42,6 +45,10 @@ func NewCollectionsViewWithSize(collectionsManager *collections.CollectionsManag } } +func (v *CollectionsView) SetDummyDataNotification(show bool) { + v.showDummyDataNotif = show +} + func (v CollectionsView) Init() tea.Cmd { return v.loadCollections } @@ -123,6 +130,11 @@ func (v CollectionsView) Update(msg tea.Msg) (CollectionsView, tea.Cmd) { break } + // Clear dummy data notification on any keypress + if v.showDummyDataNotif { + v.showDummyDataNotif = false + } + if !v.list.IsFiltering() { switch msg.String() { case "n", "right": @@ -207,6 +219,14 @@ func (v CollectionsView) View() string { 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, diff --git a/internal/tui/views/edit_collection.go b/internal/tui/views/edit_collection.go index 57d636a..a6c7170 100644 --- a/internal/tui/views/edit_collection.go +++ b/internal/tui/views/edit_collection.go @@ -49,7 +49,7 @@ func (v EditCollectionView) Update(msg tea.Msg) (EditCollectionView, tea.Cmd) { v.width = msg.Width v.height = msg.Height v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-4, v.height-8) + v.form.SetSize(v.width-50, v.height-8) case tea.KeyMsg: if v.submitting { diff --git a/internal/tui/views/endpoint_sidebar.go b/internal/tui/views/endpoint_sidebar.go index 4d99c68..42de45d 100644 --- a/internal/tui/views/endpoint_sidebar.go +++ b/internal/tui/views/endpoint_sidebar.go @@ -19,6 +19,7 @@ type EndpointSidebarView struct { initialized bool selectedIndex int endpoints []endpoints.EndpointEntity + focused bool } func NewEndpointSidebarView(endpointsManager *endpoints.EndpointsManager, collection collections.CollectionEntity) EndpointSidebarView { @@ -26,9 +27,22 @@ func NewEndpointSidebarView(endpointsManager *endpoints.EndpointsManager, collec 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 } @@ -70,12 +84,29 @@ func (v EndpointSidebarView) Update(msg tea.Msg) (EndpointSidebarView, tea.Cmd) } 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]} + } + } } } diff --git a/internal/tui/views/request_builder.go b/internal/tui/views/request_builder.go new file mode 100644 index 0000000..9cbc806 --- /dev/null +++ b/internal/tui/views/request_builder.go @@ -0,0 +1,326 @@ +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 index 6b6ed98..a935162 100644 --- a/internal/tui/views/selected_collection.go +++ b/internal/tui/views/selected_collection.go @@ -5,29 +5,49 @@ import ( "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, collection collections.CollectionEntity) SelectedCollectionView { +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: NewEndpointSidebarView(endpointsManager, collection), + sidebar: sidebar, + selectedEndpoint: nil, + requestBuilder: NewRequestBuilder(), + activeMainTab: RequestBuilderMainTab, } } -func NewSelectedCollectionViewWithSize(endpointsManager *endpoints.EndpointsManager, collection collections.CollectionEntity, width, height int) SelectedCollectionView { +func NewSelectedCollectionViewWithSize(endpointsManager *endpoints.EndpointsManager, httpManager *http.HTTPManager, collection collections.CollectionEntity, width, height int) SelectedCollectionView { layout := components.NewLayout() layout.SetSize(width, height) @@ -40,12 +60,20 @@ func NewSelectedCollectionViewWithSize(endpointsManager *endpoints.EndpointsMana 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, } @@ -72,24 +100,94 @@ func (v SelectedCollectionView) Update(msg tea.Msg) (SelectedCollectionView, tea 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 } - v.sidebar, cmd = v.sidebar.Update(msg) + // 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() - mainContent := "Endpoint details will be displayed here" + + // 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") @@ -103,14 +201,15 @@ func (v SelectedCollectionView) View() string { sidebarWidth := innerWidth / 4 mainWidth := innerWidth - sidebarWidth - 1 + // Sidebar styling sidebarStyle := styles.SidebarStyle.Copy(). Width(sidebarWidth). - Height(innerHeight) + Height(innerHeight). + BorderForeground(styles.Primary) mainStyle := styles.MainContentStyle.Copy(). Width(mainWidth). - Height(innerHeight). - Align(lipgloss.Center, lipgloss.Center) + Height(innerHeight) content := lipgloss.JoinHorizontal( lipgloss.Top, @@ -118,7 +217,13 @@ func (v SelectedCollectionView) View() string { mainStyle.Render(mainContent), ) - instructions := "↑↓: navigate endpoints • esc/q: back to collections" + 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, @@ -126,3 +231,46 @@ func (v SelectedCollectionView) View() string { 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 a6b1c12..89eedba 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/maniac-en/req/internal/backend/collections" "github.com/maniac-en/req/internal/backend/database" + "github.com/maniac-en/req/internal/backend/demo" "github.com/maniac-en/req/internal/backend/endpoints" "github.com/maniac-en/req/internal/backend/history" "github.com/maniac-en/req/internal/backend/http" @@ -140,7 +141,16 @@ func main() { historyManager, ) - log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging"}) + // populate dummy data for demo + demoGenerator := demo.NewDemoGenerator(collectionsManager, endpointsManager) + dummyDataCreated, err := demoGenerator.PopulateDummyData(context.Background()) + if err != nil { + log.Error("failed to populate dummy data", "error", err) + } else if dummyDataCreated { + appContext.SetDummyDataCreated(true) + } + + log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging", "demo"}) log.Debug("configuration loaded", "collections_manager", collectionsManager != nil, "endpoints", endpointsManager != nil, "database", db != nil, "http_manager", httpManager != nil, "history_manager", historyManager != nil) log.Info("application started successfully") diff --git a/web/banner.png b/web/banner.png new file mode 100644 index 0000000..cc80905 Binary files /dev/null and b/web/banner.png differ