diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8e63c0a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,72 @@
+name: CI/CD
+
+on:
+ push:
+ branches:
+ - main
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+ pull_request:
+ branches:
+ - main
+ - 'v[0-9]+.[0-9]+.[0-9]+'
+
+jobs:
+ build-test:
+ name: Build, Test & Coverage on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24.4'
+
+ - name: Build
+ run: go build ./...
+
+ - name: Run Tests with Coverage
+ run: |
+ go test -coverprofile=coverage.out ./...
+ go tool cover -func=coverage.out
+ shell: bash
+
+ - name: Upload coverage report
+ if: matrix.os == 'ubuntu-latest'
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage.out
+
+ format:
+ name: Auto-format with gofmt (Linux only)
+ runs-on: ubuntu-latest
+ if: github.event_name == 'push' && github.repository_owner == github.actor
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24.4'
+
+ - name: Run gofmt and commit changes if needed
+ run: |
+ gofmt -w .
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "Code was not formatted. Committing changes..."
+ git config user.name "github-actions"
+ git config user.email "github-actions@github.com"
+ git add .
+ git commit -m "chore: auto-format Go code via gofmt"
+ git push
+ else
+ echo "Code already properly formatted."
+ fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..031f668
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,43 @@
+name: Release on merge of versioned branch into main
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+
+jobs:
+ release:
+ if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'v')
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Extract version from branch name
+ run: echo "VERSION=${GITHUB_HEAD_REF}" >> $GITHUB_ENV
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24.4'
+
+ - name: Build with version
+ run: go build -ldflags "-X main.Version=${VERSION}" -o req
+
+ - name: Tag main with version
+ run: |
+ git config user.name "github-actions"
+ git config user.email "github-actions@github.com"
+ git fetch --unshallow --tags
+ git tag "${VERSION}"
+ git push origin "${VERSION}"
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ env.VERSION }}
+ files: req
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 54b62e7..e65e3ce 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,82 @@
-# req - A terminal API client
+# Req - Test APIs with Terminal Velocity
+[](https://github.com/maniac-en/req/actions/workflows/go.yml)

+
+## About
+
+`req` is a lightweight, terminal-based API client built for the
+[Boot.dev Hackathon 2025](https://github.com/maniac-en/req?tab=License-1-ov-file).
+It features a fast and minimal text user interface and lets you create, send,
+and inspect HTTP requests interactively from the command line. It is ideal for
+testing APIs without leaving your terminal.
+
+Read more about `req` over here -
+[Announcement Blog](https://maniac-en.github.io/req/)
+
+## Installation
+
+### You can install `req` using `go install`:
+
+To install a specific release
+
+```
+go install github.com/maniac-en/req@v0.1.0
+```
+
+Replace `v0.1.0` with the version you want.
+
+### Requirements
+
+- Go version 1.24.4
+
+## Usage
+
+After installing `req`, you can run it using this command.
+
+```
+req
+```
+
+## Libraries Used
+
+### Terminal UI (by Charm.sh)
+
+- [bubbletea](https://github.com/charmbracelet/bubbletea) — A powerful, fun TUI
+ framework for Go
+- [bubbles](https://github.com/charmbracelet/bubbles) — Pre-built components for
+ TUI apps
+- [lipgloss](https://github.com/charmbracelet/lipgloss) — Terminal style/layout
+ DSL
+
+## License
+
+This project is licensed under the
+[MIT License](https://github.com/maniac-en/req?tab=License-1-ov-file).
+
+```
+1. Mudassir Bilal (mailto:mughalmudassir966@gmail.com)
+2. Shivam Mehta (mailto:sm.cse17@gmail.com)
+3. Yash Ranjan (mailto:yash.ranjan25@gmail.com)
+
+MIT License
+
+Copyright (c) 2025 Mudassir Bilal, Shivam Mehta, Yash Ranjan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
diff --git a/docs/images/banner.png b/docs/images/banner.png
new file mode 100644
index 0000000..cc80905
Binary files /dev/null and b/docs/images/banner.png differ
diff --git a/docs/images/req-demo.gif b/docs/images/req-demo.gif
new file mode 100644
index 0000000..00d3535
Binary files /dev/null and b/docs/images/req-demo.gif differ
diff --git a/docs/index.html b/docs/index.html
index 127e1e4..172eea3 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,27 +4,19 @@
- req - Terminal API Client
+ Req - Test APIs with Terminal Velocity
- req - Terminal API Client Note : This page is not up to date and serves as a boilerplate for the blog setup.A terminal-based API client built for the Boot.dev Hackathon 2025 .
Features Terminal user interface Request collections Environment variables Request history Tech Stack The project uses:
Go for core logicBubble Tea for TUISQLite for storageCode Example go
-func main() {
- fmt.Println("Hello, req!")
-}
- Installation bash
-go build -o req .
-./req
- Commands req --help - Show helpreq --verbose - Verbose outputLists Test Unordered list:
-- Item one
-- Item two
-- Item three
Ordered list:
-1. First step
-2. Second step
-3. Third step
Text Formatting This has bold text , italic text , and inline code.
---
This blog is built with ❤️ using pyssg - A guided learning project at boot.dev
+ Req - Test APIs with Terminal Velocity A terminal-based API client built for the Boot.dev Hackathon 2025 .
Features Terminal user interface with beautiful TUI Request collections and organization Demo data generation with realistic APIs Request builder with tabs for body, headers, query params Production-ready logging system Tech Stack The project uses:
Go for core logic and HTTP operationsBubble Tea for terminal user interfaceSQLite for file-based storageSQLC for type-safe database operationsGoose for database migrationsInstallation bash
+go install github.com/maniac-en/req@v0.1.0
+req
+ What's Implemented Collections CRUD operations (create, edit, delete, navigate) Request builder interface with tabbed editing Endpoint browsing with sidebar navigation Demo data generation (JSONPlaceholder, ReqRes, HTTPBin APIs) Beautiful warm color scheme with vim-like navigation Pagination and real-time search filtering Coming Soon HTTP request execution (core feature) Response viewer with syntax highlighting Endpoint management (add/edit endpoints) Environment variables support Export/import functionality Try It Out GitHub : https://github.com/maniac-en/req
+Installation : go install github.com/maniac-en/req@v0.1.0
+Usage : Just run req in your terminal!
The app works completely offline with no external dependencies required.
---
This blog is built with ❤️ using pyssg - A guided learning project at boot.dev
diff --git a/global/context.go b/global/context.go
deleted file mode 100644
index f46358b..0000000
--- a/global/context.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package global
-
-import (
- "github.com/maniac-en/req/internal/collections"
- "github.com/maniac-en/req/internal/endpoints"
- "github.com/maniac-en/req/internal/history"
- "github.com/maniac-en/req/internal/http"
-)
-
-type AppContext struct {
- Collections *collections.CollectionsManager
- Endpoints *endpoints.EndpointsManager
- HTTP *http.HTTPManager
- History *history.HistoryManager
-}
-
-var globalAppContext *AppContext
-
-func SetAppContext(ctx *AppContext) {
- globalAppContext = ctx
-}
-
-func GetAppContext() *AppContext {
- return globalAppContext
-}
diff --git a/global/state.go b/global/state.go
deleted file mode 100644
index e9d8094..0000000
--- a/global/state.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package global
-
-type State struct {
- currentCollection string
-}
-
-func NewGlobalState() *State {
- return &State{
- currentCollection: "",
- }
-}
-
-// Gets the current collection from the app state
-func (s *State) GetCurrentCollection() string {
- return s.currentCollection
-}
-
-// Sets the current collection to the app state
-func (s *State) SetCurrentCollection(collection string) {
- s.currentCollection = collection
-}
diff --git a/internal/app/model.go b/internal/app/model.go
deleted file mode 100644
index 9f01bbb..0000000
--- a/internal/app/model.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package app
-
-import (
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/messages"
- "github.com/maniac-en/req/internal/tabs"
-)
-
-type Model struct {
- tabs []tabs.Tab
- activeTab int
- width int
- height int
-
- // Global state for sharing data
- state *global.State
-}
-
-func InitialModel() Model {
-
- globalState := global.NewGlobalState()
-
- return Model{
- state: globalState,
- tabs: []tabs.Tab{
- tabs.NewCollectionsTab(globalState),
- tabs.NewAddCollectionTab(),
- tabs.NewEditCollectionTab(),
- tabs.NewEndpointsTab(globalState),
- tabs.NewAddEndpointTab(globalState),
- tabs.NewEditEndpointTab(globalState),
- },
- }
-
-}
-
-func (m Model) Init() tea.Cmd {
- return m.tabs[m.activeTab].Init()
-}
-
-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- switch msg := msg.(type) {
-
- case messages.SwitchTabMsg:
- if msg.TabIndex >= 0 && msg.TabIndex < len(m.tabs) {
- m.activeTab = msg.TabIndex
- return m, m.tabs[m.activeTab].OnFocus()
- }
- return m, nil
-
- case messages.EditCollectionMsg:
- if editTab, ok := m.tabs[2].(*tabs.EditCollectionTab); ok {
- editTab.SetEditingCollection(msg.Label, msg.Value)
- }
- return m, nil
- case messages.EditEndpointMsg:
- if editTab, ok := m.tabs[5].(*tabs.EditEndpointTab); ok {
- editTab.SetEditingEndpoint(msg)
- }
- return m, nil
-
- case tea.KeyMsg:
- switch msg.String() {
- // removed q here because that was causing issues with input fields
- case "ctrl+c":
- return m, tea.Quit
- default:
- m.tabs[m.activeTab], cmd = m.tabs[m.activeTab].Update(msg)
- return m, cmd
- }
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- return m, nil
- default:
- m.tabs[m.activeTab], cmd = m.tabs[m.activeTab].Update(msg)
- return m, cmd
- }
-}
-
-func (m Model) View() string {
- const headerFooterHeight = 1
- const padding = 1
- headerText := m.tabs[m.activeTab].Name()
- instructions := m.tabs[m.activeTab].Instructions()
-
- headerStyle := lipgloss.NewStyle().
- Padding(1, 0).
- Background(lipgloss.Color("62")).
- Foreground(lipgloss.Color("230")).
- Height(headerFooterHeight).
- Width(len(headerText)+10).
- Align(lipgloss.Center, lipgloss.Top)
-
- footerStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("45")).
- Width(m.width-50).
- PaddingBottom(1).
- Height(headerFooterHeight).
- Align(lipgloss.Center, lipgloss.Center)
-
- renderedHeader := headerStyle.Render(headerText)
- renderedFooter := footerStyle.Render(instructions)
-
- headerHeight := lipgloss.Height(renderedHeader)
- footerHeight := lipgloss.Height(renderedFooter)
-
- contentHeight := m.height - headerHeight - footerHeight
- contentStyle := lipgloss.NewStyle().
- Width(m.width).
- Height(contentHeight).
- Align(lipgloss.Center, lipgloss.Center)
-
- content := m.tabs[m.activeTab].View()
-
- return lipgloss.JoinVertical(lipgloss.Center, renderedHeader, contentStyle.Render(content), renderedFooter)
-}
diff --git a/internal/collections/manager.go b/internal/backend/collections/manager.go
similarity index 97%
rename from internal/collections/manager.go
rename to internal/backend/collections/manager.go
index 9403d59..5c587d2 100644
--- a/internal/collections/manager.go
+++ b/internal/backend/collections/manager.go
@@ -4,8 +4,8 @@ import (
"context"
"database/sql"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
"github.com/maniac-en/req/internal/log"
)
diff --git a/internal/collections/manager_test.go b/internal/backend/collections/manager_test.go
similarity index 97%
rename from internal/collections/manager_test.go
rename to internal/backend/collections/manager_test.go
index 51863cf..42ace4f 100644
--- a/internal/collections/manager_test.go
+++ b/internal/backend/collections/manager_test.go
@@ -5,8 +5,8 @@ import (
"fmt"
"testing"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/testutils"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/testutils"
)
func TestCollectionsManagerCRUD(t *testing.T) {
diff --git a/internal/collections/models.go b/internal/backend/collections/models.go
similarity index 84%
rename from internal/collections/models.go
rename to internal/backend/collections/models.go
index 7e60791..94b46db 100644
--- a/internal/collections/models.go
+++ b/internal/backend/collections/models.go
@@ -3,8 +3,8 @@ package collections
import (
"time"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
)
type CollectionEntity struct {
diff --git a/internal/crud/interfaces.go b/internal/backend/crud/interfaces.go
similarity index 92%
rename from internal/crud/interfaces.go
rename to internal/backend/crud/interfaces.go
index bf27f08..a7bf11c 100644
--- a/internal/crud/interfaces.go
+++ b/internal/backend/crud/interfaces.go
@@ -38,7 +38,10 @@ type PaginationMetadata struct {
}
func CalculatePagination(total int64, limit, offset int) PaginationMetadata {
- totalPages := int((total + int64(limit) - 1) / int64(limit)) // Ceiling division
+ totalPages := 1
+ if total > 0 {
+ totalPages = int((total + int64(limit) - 1) / int64(limit)) // Ceiling division
+ }
currentPage := (offset / limit) + 1
hasNext := (offset + limit) < int(total)
hasPrev := offset > 0
diff --git a/internal/crud/interfaces_test.go b/internal/backend/crud/interfaces_test.go
similarity index 100%
rename from internal/crud/interfaces_test.go
rename to internal/backend/crud/interfaces_test.go
diff --git a/internal/crud/utils.go b/internal/backend/crud/utils.go
similarity index 100%
rename from internal/crud/utils.go
rename to internal/backend/crud/utils.go
diff --git a/internal/crud/utils_test.go b/internal/backend/crud/utils_test.go
similarity index 100%
rename from internal/crud/utils_test.go
rename to internal/backend/crud/utils_test.go
diff --git a/internal/database/collections.sql.go b/internal/backend/database/collections.sql.go
similarity index 100%
rename from internal/database/collections.sql.go
rename to internal/backend/database/collections.sql.go
diff --git a/internal/database/db.go b/internal/backend/database/db.go
similarity index 100%
rename from internal/database/db.go
rename to internal/backend/database/db.go
diff --git a/internal/database/endpoints.sql.go b/internal/backend/database/endpoints.sql.go
similarity index 100%
rename from internal/database/endpoints.sql.go
rename to internal/backend/database/endpoints.sql.go
diff --git a/internal/database/history.sql.go b/internal/backend/database/history.sql.go
similarity index 100%
rename from internal/database/history.sql.go
rename to internal/backend/database/history.sql.go
diff --git a/internal/database/models.go b/internal/backend/database/models.go
similarity index 100%
rename from internal/database/models.go
rename to internal/backend/database/models.go
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/endpoints/manager.go b/internal/backend/endpoints/manager.go
similarity index 98%
rename from internal/endpoints/manager.go
rename to internal/backend/endpoints/manager.go
index 8b92ef3..196b15f 100644
--- a/internal/endpoints/manager.go
+++ b/internal/backend/endpoints/manager.go
@@ -6,8 +6,8 @@ import (
"encoding/json"
"fmt"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
"github.com/maniac-en/req/internal/log"
)
diff --git a/internal/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go
similarity index 99%
rename from internal/endpoints/manager_test.go
rename to internal/backend/endpoints/manager_test.go
index 9119cbc..d8aa701 100644
--- a/internal/endpoints/manager_test.go
+++ b/internal/backend/endpoints/manager_test.go
@@ -4,8 +4,8 @@ import (
"context"
"testing"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/testutils"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/testutils"
)
func TestEndpointsManagerCRUD(t *testing.T) {
diff --git a/internal/endpoints/models.go b/internal/backend/endpoints/models.go
similarity index 87%
rename from internal/endpoints/models.go
rename to internal/backend/endpoints/models.go
index d796f4c..1447b8c 100644
--- a/internal/endpoints/models.go
+++ b/internal/backend/endpoints/models.go
@@ -3,8 +3,8 @@ package endpoints
import (
"time"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
)
type EndpointEntity struct {
diff --git a/internal/history/manager.go b/internal/backend/history/manager.go
similarity index 98%
rename from internal/history/manager.go
rename to internal/backend/history/manager.go
index c99e04b..c7931d7 100644
--- a/internal/history/manager.go
+++ b/internal/backend/history/manager.go
@@ -7,8 +7,8 @@ import (
"fmt"
"time"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
"github.com/maniac-en/req/internal/log"
)
diff --git a/internal/history/manager_test.go b/internal/backend/history/manager_test.go
similarity index 99%
rename from internal/history/manager_test.go
rename to internal/backend/history/manager_test.go
index 69c1226..cd43dc8 100644
--- a/internal/history/manager_test.go
+++ b/internal/backend/history/manager_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"time"
- "github.com/maniac-en/req/internal/testutils"
+ "github.com/maniac-en/req/internal/backend/testutils"
)
func TestHistoryManagerCRUD(t *testing.T) {
diff --git a/internal/history/models.go b/internal/backend/history/models.go
similarity index 91%
rename from internal/history/models.go
rename to internal/backend/history/models.go
index 022386f..8df5d0d 100644
--- a/internal/history/models.go
+++ b/internal/backend/history/models.go
@@ -4,8 +4,8 @@ package history
import (
"time"
- "github.com/maniac-en/req/internal/crud"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/crud"
+ "github.com/maniac-en/req/internal/backend/database"
)
type HistoryManager struct {
diff --git a/internal/http/manager.go b/internal/backend/http/manager.go
similarity index 100%
rename from internal/http/manager.go
rename to internal/backend/http/manager.go
diff --git a/internal/http/manager_test.go b/internal/backend/http/manager_test.go
similarity index 100%
rename from internal/http/manager_test.go
rename to internal/backend/http/manager_test.go
diff --git a/internal/http/models.go b/internal/backend/http/models.go
similarity index 100%
rename from internal/http/models.go
rename to internal/backend/http/models.go
diff --git a/internal/testutils/database.go b/internal/backend/testutils/database.go
similarity index 97%
rename from internal/testutils/database.go
rename to internal/backend/testutils/database.go
index dad9cdb..e3aa723 100644
--- a/internal/testutils/database.go
+++ b/internal/backend/testutils/database.go
@@ -6,7 +6,7 @@ import (
"database/sql"
"testing"
- "github.com/maniac-en/req/internal/database"
+ "github.com/maniac-en/req/internal/backend/database"
_ "github.com/mattn/go-sqlite3"
)
diff --git a/internal/log/logger.go b/internal/log/logger.go
index 322b3c8..3e3f820 100644
--- a/internal/log/logger.go
+++ b/internal/log/logger.go
@@ -3,11 +3,9 @@ package log
import (
"context"
- "fmt"
"log/slog"
"os"
"sync"
- "time"
"gopkg.in/natefinch/lumberjack.v2"
)
@@ -103,11 +101,6 @@ func Fatal(msg string, args ...any) {
os.Exit(1)
}
-// Request ID utilities
-func GenerateRequestID() string {
- return fmt.Sprintf("req_%d", time.Now().UnixNano())
-}
-
type contextKey string
const requestIDKey contextKey = "request_id"
diff --git a/internal/log/logger_test.go b/internal/log/logger_test.go
index d4648e5..33db4ad 100644
--- a/internal/log/logger_test.go
+++ b/internal/log/logger_test.go
@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"os"
- "strings"
"testing"
)
@@ -47,18 +46,6 @@ func TestInitialize(t *testing.T) {
}
func TestRequestIDFunctions(t *testing.T) {
- t.Run("generates unique request IDs", func(t *testing.T) {
- id1 := GenerateRequestID()
- id2 := GenerateRequestID()
-
- if id1 == id2 {
- t.Error("IDs should be unique")
- }
- if !strings.HasPrefix(id1, "req_") {
- t.Error("ID should have req_ prefix")
- }
- })
-
t.Run("context request ID functions", func(t *testing.T) {
ctx := ContextWithRequestID(context.Background(), "test123")
retrieved := RequestIDFromContext(ctx)
diff --git a/internal/messages/messages.go b/internal/messages/messages.go
deleted file mode 100644
index ddb6a66..0000000
--- a/internal/messages/messages.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package messages
-
-type SwitchTabMsg struct {
- TabIndex int
-}
-
-type EditCollectionMsg struct {
- Label string
- Value string
-}
-
-type EditEndpointMsg struct {
- Name string
- Method string
- URL string
- ID string
-}
diff --git a/internal/tabs/add-collections.go b/internal/tabs/add-collections.go
deleted file mode 100644
index 7c1b15d..0000000
--- a/internal/tabs/add-collections.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type AddCollectionTab struct {
- name string
- nameInput textinput.Model
- focused bool
-}
-
-func NewAddCollectionTab() *AddCollectionTab {
- textInput := textinput.New()
- textInput.Placeholder = "Enter your collection's name... "
- textInput.Focus()
-
- textInput.CharLimit = 100
- textInput.Width = 50
-
- return &AddCollectionTab{
- name: "Add Collection",
- nameInput: textInput,
- focused: true,
- }
-}
-
-func (a *AddCollectionTab) Name() string {
- return a.name
-}
-
-func (a *AddCollectionTab) Instructions() string {
- return "Enter - create • Esc - cancel"
-}
-
-func (a *AddCollectionTab) Init() tea.Cmd {
- return textinput.Blink
-}
-
-func (a *AddCollectionTab) OnFocus() tea.Cmd {
- a.nameInput.Focus()
- a.focused = true
- return textinput.Blink
-}
-
-func (a *AddCollectionTab) OnBlur() tea.Cmd {
- a.nameInput.Blur()
- a.focused = false
- return nil
-}
-
-func (a *AddCollectionTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
-
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if a.nameInput.Value() != "" {
- return a.addCollection(a.nameInput.Value())
- }
- case "esc":
- return a, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 0}
- }
- }
- }
-
- a.nameInput, _ = a.nameInput.Update(msg)
- return a, nil
-}
-
-func (a *AddCollectionTab) View() string {
- titleStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color("205")).
- MarginBottom(2)
-
- inputStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("62")).
- Padding(1, 2).
- MarginBottom(2)
-
- form := lipgloss.JoinVertical(lipgloss.Center,
- titleStyle.Render("Create New Collection"),
- inputStyle.Render(a.nameInput.View()),
- )
-
- containerStyle := lipgloss.NewStyle().
- Width(60).
- Height(20).
- Align(lipgloss.Center, lipgloss.Center)
-
- return containerStyle.Render(form)
-}
-
-func (a *AddCollectionTab) addCollection(name string) (Tab, tea.Cmd) {
- ctx := global.GetAppContext()
- collection, _ := ctx.Collections.Create(context.Background(), name)
- value := strconv.Itoa(int(collection.GetID()))
- newOption := OptionPair{
- Label: collection.GetName(),
- Value: value,
- }
-
- GlobalCollections = append(GlobalCollections, newOption)
-
- a.nameInput.SetValue("")
-
- return a, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 0}
- }
-}
diff --git a/internal/tabs/add-endpoint.go b/internal/tabs/add-endpoint.go
deleted file mode 100644
index 444e750..0000000
--- a/internal/tabs/add-endpoint.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/endpoints"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type AddEndpointTab struct {
- name string
- inputs []textinput.Model
- focusedInput int
- state *global.State
- focused bool
-}
-
-func NewAddEndpointTab(globalState *global.State) *AddEndpointTab {
- name := textinput.New()
- name.Placeholder = "Enter your endpoint's name... "
- name.CharLimit = 100
- name.Width = 50
-
- method := textinput.New()
- method.Placeholder = "Enter your method... "
- method.CharLimit = 100
- method.Width = 50
-
- url := textinput.New()
- url.Placeholder = "Enter your url..."
- url.CharLimit = 100
- url.Width = 50
-
- name.Focus()
-
- return &AddEndpointTab{
- name: "Add Endpoint",
- inputs: []textinput.Model{
- name,
- method,
- url,
- },
- focused: true,
- state: globalState,
- }
-}
-
-func (a *AddEndpointTab) Name() string {
- return a.name
-}
-func (a *AddEndpointTab) Instructions() string {
- return "none"
-}
-func (a *AddEndpointTab) Init() tea.Cmd {
- return textinput.Blink
-}
-func (a *AddEndpointTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if a.inputs[0].Value() != "" && a.inputs[1].Value() != "" && a.inputs[2].Value() != "" {
- return a.addEndpoint(a.inputs[0].Value(), a.inputs[1].Value(), a.inputs[2].Value())
- }
- return a, nil
- case "tab":
- a.inputs[a.focusedInput].Blur()
- a.focusedInput = (a.focusedInput + 1) % len(a.inputs)
- a.inputs[a.focusedInput].Focus()
- case "esc":
- return a, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
- }
- }
-
- a.inputs[a.focusedInput], _ = a.inputs[a.focusedInput].Update(msg)
- return a, nil
-}
-func (a *AddEndpointTab) View() string {
- titleStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color("205")).
- MarginBottom(2)
-
- inputStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("62")).
- Padding(1, 2).
- MarginBottom(2)
-
- form := lipgloss.JoinVertical(lipgloss.Center,
- titleStyle.Render("Create New Endpoint"),
- inputStyle.Render(a.inputs[0].View()),
- inputStyle.Render(a.inputs[1].View()),
- inputStyle.Render(a.inputs[2].View()),
- )
-
- containerStyle := lipgloss.NewStyle().
- Width(60).
- Height(20).
- Align(lipgloss.Center, lipgloss.Center)
-
- return containerStyle.Render(form)
-}
-
-func (a *AddEndpointTab) OnFocus() tea.Cmd {
- a.inputs[a.focusedInput].Focus()
- a.focused = true
- return textinput.Blink
-}
-
-func (a *AddEndpointTab) OnBlur() tea.Cmd {
- a.inputs[0].Blur()
- a.inputs[1].Blur()
- a.inputs[2].Blur()
- a.focused = false
- return nil
-}
-
-func (a *AddEndpointTab) addEndpoint(name, method, url string) (Tab, tea.Cmd) {
- ctx := global.GetAppContext()
-
- collectionId := a.state.GetCurrentCollection()
- int64Collection, err := strconv.ParseInt(collectionId, 10, 64)
- if err != nil {
- return a, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
- }
-
- _, _ = ctx.Endpoints.CreateEndpoint(context.Background(), endpoints.EndpointData{
- Name: name,
- Method: method,
- URL: url,
- CollectionID: int64Collection,
- })
-
- // newOption := OptionPair{
- // Label: collection.GetName(),
- // Value: string(collection.GetID()),
- // }
-
- a.inputs[0].SetValue("")
- a.inputs[1].SetValue("")
- a.inputs[2].SetValue("")
-
- return a, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
-}
diff --git a/internal/tabs/collections.go b/internal/tabs/collections.go
deleted file mode 100644
index 5f6429a..0000000
--- a/internal/tabs/collections.go
+++ /dev/null
@@ -1,212 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type collectionsOpts struct {
- options []OptionPair
- totalItems int
-}
-
-type OptionPair struct {
- Label string
- Value string
-}
-
-type CollectionsTab struct {
- name string
- selectUI SelectInput
- loaded bool
- currentPage int
- itemsPerPage int
- totalCollections int
- globalState *global.State
-}
-
-func NewCollectionsTab(state *global.State) *CollectionsTab {
- itemsPerPage := 5
- return &CollectionsTab{
- name: "Collections",
- selectUI: NewSelectInput(),
- loaded: false,
- currentPage: 0,
- itemsPerPage: itemsPerPage,
- globalState: state,
- }
-}
-
-func (c *CollectionsTab) IsFiltering() bool {
- return c.selectUI.list.FilterState() == list.Filtering
-}
-
-func (c *CollectionsTab) fetchOptions(limit, offset int) tea.Cmd {
- ctx := global.GetAppContext()
- paginatedCollections, err := ctx.Collections.ListPaginated(context.Background(), limit, offset)
- if err != nil {
- }
- options := []OptionPair{}
- for i, _ := range paginatedCollections.Collections {
- options = append(options, OptionPair{
- Label: paginatedCollections.Collections[i].GetName(),
- Value: strconv.FormatInt(paginatedCollections.Collections[i].GetID(), 10),
- })
- }
- GlobalCollections = options
- c.totalCollections = int(paginatedCollections.Total)
- return func() tea.Msg {
- return collectionsOpts{
- options: GlobalCollections,
- totalItems: c.totalCollections,
- }
- }
-}
-
-func (c *CollectionsTab) Name() string {
- return c.name
-}
-
-func (c *CollectionsTab) Instructions() string {
- return "\n k - up • j - down • / - search • + - add collection • enter - select • d - delete collection • e - edit collection • h - prev page • l - next page"
-}
-
-func (c *CollectionsTab) Init() tea.Cmd {
- c.selectUI.Focus()
- return tea.Batch(
- c.selectUI.Init(),
- c.fetchOptions(c.itemsPerPage, 0),
- )
-}
-
-func (c *CollectionsTab) OnFocus() tea.Cmd {
- c.selectUI.Focus()
-
- return c.fetchOptions(c.itemsPerPage, c.currentPage*c.itemsPerPage)
-}
-
-func (c *CollectionsTab) OnBlur() tea.Cmd {
- c.selectUI.Blur()
- return nil
-}
-
-func (c *CollectionsTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
- var cmd tea.Cmd
-
- switch msg := msg.(type) {
- case collectionsOpts:
- c.selectUI.SetOptions(msg.options)
- c.loaded = true
-
- case tea.KeyMsg:
- // Check if list is filtering otherwise the keybinds wouldn't let us type
- if c.IsFiltering() {
- c.selectUI, cmd = c.selectUI.Update(msg)
- return c, cmd
- }
-
- switch msg.String() {
- case "+":
- return c, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 1}
- }
- case "d":
- if selected := c.selectUI.GetSelected(); selected != "" {
- return c, c.deleteCollection(selected)
- }
- case "e":
- if selected := c.selectUI.GetSelected(); selected != "" {
- return c, c.editCollection(selected)
- }
- case "h":
- if c.currentPage > 0 {
- c.currentPage--
- newOffset := c.currentPage * c.itemsPerPage
- return c, c.fetchOptions(c.itemsPerPage, newOffset)
- }
- case "l":
- totalPages := (c.totalCollections + c.itemsPerPage - 1) / c.itemsPerPage
- if c.currentPage < totalPages-1 {
- c.currentPage++
- newOffset := c.currentPage * c.itemsPerPage
- return c, c.fetchOptions(c.itemsPerPage, newOffset)
- }
- case "enter":
- c.globalState.SetCurrentCollection(c.selectUI.GetSelected())
- return c, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
- default:
- c.selectUI, cmd = c.selectUI.Update(msg)
- }
-
- default:
- c.selectUI, cmd = c.selectUI.Update(msg)
- }
-
- return c, cmd
-}
-
-func (c *CollectionsTab) View() string {
-
- if c.selectUI.IsLoading() {
- return c.selectUI.View()
- }
-
- selectContent := c.selectUI.View()
-
- style := lipgloss.NewStyle().
- PaddingRight(4)
-
- if !c.selectUI.IsLoading() && len(c.selectUI.list.Items()) > 0 {
- title := "\n\n\n\n\n\n\nSelect Collection:\n\n"
- return title + style.Render(selectContent)
- }
-
- return style.Render(selectContent)
-
-}
-
-func (c *CollectionsTab) deleteCollection(value string) tea.Cmd {
- ctx := global.GetAppContext()
- id, _ := strconv.Atoi(value)
- err := ctx.Collections.Delete(context.Background(), int64(id))
- if err != nil {
- return c.fetchOptions(c.itemsPerPage, c.currentPage*c.itemsPerPage)
- }
- for i, collection := range GlobalCollections {
- if collection.Value == value {
- GlobalCollections = append(GlobalCollections[:i], GlobalCollections[i+1:]...)
- break
- }
- }
- return c.fetchOptions(c.itemsPerPage, c.currentPage*c.itemsPerPage)
-}
-
-func (c *CollectionsTab) editCollection(value string) tea.Cmd {
- var label string
- for _, collection := range GlobalCollections {
- if collection.Value == value {
- label = collection.Label
- break
- }
- }
-
- return tea.Batch(
- func() tea.Msg {
- return messages.EditCollectionMsg{
- Label: label,
- Value: value,
- }
- },
- func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 2}
- },
- )
-}
diff --git a/internal/tabs/components.go b/internal/tabs/components.go
deleted file mode 100644
index 678fe5e..0000000
--- a/internal/tabs/components.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package tabs
-
-import (
- "fmt"
- "io"
- "strings"
-
- "github.com/charmbracelet/bubbles/list"
- "github.com/charmbracelet/bubbles/spinner"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-// for now the focus is just on the collections tab
-// we'll see how we can change this around to accommodate
-// more tabs
-type item struct {
- label string
- value string
-}
-
-type renderMethod struct{}
-
-func (i item) FilterValue() string { return i.label }
-func (i item) Label() string { return i.label }
-func (i item) Value() string { return i.value }
-
-func (r renderMethod) Height() int {
- return 1
-}
-
-func (r renderMethod) Spacing() int {
- return 0
-}
-
-func (r renderMethod) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
- return nil
-}
-func (r renderMethod) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
- i, ok := listItem.(item)
- if !ok {
- return
- }
-
- str := i.Label()
-
- fn := lipgloss.NewStyle().PaddingLeft(4).Render
- if index == m.Index() {
- fn = func(s ...string) string {
- return lipgloss.NewStyle().
- Foreground(lipgloss.Color("170")).
- Bold(true).
- PaddingLeft(2).
- Render("> " + strings.Join(s, " "))
- }
- }
-
- fmt.Fprint(w, fn(str))
-}
-
-type SelectInput struct {
- list list.Model
- loading bool
- focused bool
- spinner spinner.Model
-}
-
-func NewSelectInput() SelectInput {
- l := list.New([]list.Item{}, renderMethod{}, 50, 14)
- l.SetShowStatusBar(false)
- l.SetFilteringEnabled(true)
- l.SetShowHelp(false)
- l.SetShowTitle(false)
-
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
-
- return SelectInput{
- list: l,
- loading: true,
- focused: false,
- spinner: s,
- }
-}
-
-func (s SelectInput) Init() tea.Cmd {
- return s.spinner.Tick
-}
-
-func (s SelectInput) Update(msg tea.Msg) (SelectInput, tea.Cmd) {
- var cmd tea.Cmd
-
- if s.loading {
- s.spinner, cmd = s.spinner.Update(msg)
- return s, cmd
- }
-
- if s.focused && !s.loading {
- s.list, cmd = s.list.Update(msg)
- }
-
- return s, cmd
-}
-
-func (s SelectInput) View() string {
- if s.loading {
- return fmt.Sprintf("%s Loading options...", s.spinner.View())
- }
-
- // Add this check for empty options
- if len(s.list.Items()) == 0 {
- const bodyText = "No options available\nCreate your first option to get started!"
- emptyStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("240")).
- Italic(true).
- Align(lipgloss.Center).
- PaddingTop(5).
- Render(bodyText)
-
- return emptyStyle
- }
-
- return s.list.View()
-}
-
-func (s SelectInput) Focused() bool { return s.focused }
-func (s *SelectInput) Focus() { s.focused = true }
-func (s *SelectInput) Blur() { s.focused = false }
-func (s SelectInput) IsLoading() bool { return s.loading }
-
-func (s *SelectInput) SetOptions(options []OptionPair) {
- items := make([]list.Item, len(options))
- for i, option := range options {
- items[i] = item{label: option.Label, value: option.Value}
- }
- s.list.SetItems(items)
- s.loading = false
-}
-
-func (s SelectInput) GetSelected() string {
- if s.loading || len(s.list.Items()) == 0 {
- return ""
- }
- if selectedItem := s.list.SelectedItem(); selectedItem != nil {
- return selectedItem.(item).Value()
- }
- return ""
-}
diff --git a/internal/tabs/edit-collections.go b/internal/tabs/edit-collections.go
deleted file mode 100644
index 6c77b5d..0000000
--- a/internal/tabs/edit-collections.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type EditCollectionTab struct {
- name string
- nameInput textinput.Model
- originalValue string
- focused bool
-}
-
-func NewEditCollectionTab() *EditCollectionTab {
- textInput := textinput.New()
- textInput.Placeholder = "Enter collection name..."
- textInput.CharLimit = 50
- textInput.Width = 30
-
- return &EditCollectionTab{
- name: "Edit Collection",
- nameInput: textInput,
- focused: true,
- }
-}
-
-func (e *EditCollectionTab) Name() string {
- return e.name
-}
-
-func (e *EditCollectionTab) Instructions() string {
- return "Enter - create • Esc - cancel"
-}
-
-func (e *EditCollectionTab) Init() tea.Cmd {
- return textinput.Blink
-}
-
-func (e *EditCollectionTab) OnFocus() tea.Cmd {
- e.nameInput.Focus()
- e.focused = true
- return textinput.Blink
-}
-
-func (e *EditCollectionTab) OnBlur() tea.Cmd {
- e.nameInput.Blur()
- e.focused = false
- return nil
-}
-
-func (e *EditCollectionTab) SetEditingCollection(label, value string) {
- e.nameInput.SetValue(label)
- e.originalValue = value
-}
-
-func (e *EditCollectionTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
- var cmd tea.Cmd
-
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if e.nameInput.Value() != "" {
- return e.updateCollection(e.nameInput.Value())
- }
- return e, nil
- case "esc":
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 0}
- }
- default:
- e.nameInput, cmd = e.nameInput.Update(msg)
- return e, cmd
- }
- default:
- e.nameInput, cmd = e.nameInput.Update(msg)
- return e, cmd
- }
-}
-
-func (e *EditCollectionTab) View() string {
- titleStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color("205")).
- MarginBottom(2)
-
- inputStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("62")).
- Padding(1, 2).
- MarginBottom(2)
-
- form := lipgloss.JoinVertical(lipgloss.Center,
- titleStyle.Render("Edit Collection"),
- inputStyle.Render(e.nameInput.View()),
- )
-
- containerStyle := lipgloss.NewStyle().
- Width(60).
- Height(20).
- Align(lipgloss.Center, lipgloss.Center)
-
- return containerStyle.Render(form)
-}
-
-func (e *EditCollectionTab) updateCollection(newName string) (Tab, tea.Cmd) {
- ctx := global.GetAppContext()
- id, _ := strconv.Atoi(e.originalValue)
- ctx.Collections.Update(context.Background(), int64(id), newName)
- for i, collection := range GlobalCollections {
- if collection.Value == e.originalValue {
- GlobalCollections[i] = OptionPair{
- Label: newName,
- Value: e.originalValue,
- }
- break
- }
- }
-
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 0}
- }
-}
diff --git a/internal/tabs/edit-endpoint.go b/internal/tabs/edit-endpoint.go
deleted file mode 100644
index 7c3f169..0000000
--- a/internal/tabs/edit-endpoint.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/endpoints"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type EditEndpointTab struct {
- name string
- inputs []textinput.Model
- endpointID string
- focusedInput int
- focused bool
- state *global.State
-}
-
-func NewEditEndpointTab(globalState *global.State) *EditEndpointTab {
- name := textinput.New()
- name.Placeholder = "Enter your endpoint's name... "
- name.CharLimit = 100
- name.Width = 50
-
- method := textinput.New()
- method.Placeholder = "Enter your method... "
- method.CharLimit = 100
- method.Width = 50
-
- url := textinput.New()
- url.Placeholder = "Enter your url..."
- url.CharLimit = 100
- url.Width = 50
-
- name.Focus()
-
- return &EditEndpointTab{
- name: "Add Endpoint",
- inputs: []textinput.Model{
- name,
- method,
- url,
- },
- focusedInput: 0,
- focused: true,
- state: globalState,
- }
-}
-
-func (e *EditEndpointTab) Name() string {
- return e.name
-}
-func (e *EditEndpointTab) Instructions() string {
- return "None"
-}
-func (e *EditEndpointTab) Init() tea.Cmd {
- return textinput.Blink
-}
-func (e *EditEndpointTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- if e.inputs[0].Value() != "" && e.inputs[1].Value() != "" && e.inputs[2].Value() != "" {
- return e.updateEndpoint(e.inputs[0].Value(), e.inputs[1].Value(), e.inputs[2].Value())
- }
- return e, nil
- case "tab":
- e.inputs[e.focusedInput].Blur()
- e.focusedInput = (e.focusedInput + 1) % len(e.inputs)
- e.inputs[e.focusedInput].Focus()
- case "esc":
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
- }
- }
-
- e.inputs[e.focusedInput], _ = e.inputs[e.focusedInput].Update(msg)
- return e, nil
-}
-func (e *EditEndpointTab) View() string {
- titleStyle := lipgloss.NewStyle().
- Bold(true).
- Foreground(lipgloss.Color("205")).
- MarginBottom(2)
-
- inputStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("62")).
- Padding(1, 2).
- MarginBottom(2)
-
- form := lipgloss.JoinVertical(
- lipgloss.Center,
- titleStyle.Render("Edit Collection"),
- inputStyle.Render(e.inputs[0].View()),
- inputStyle.Render(e.inputs[1].View()),
- inputStyle.Render(e.inputs[2].View()),
- )
-
- containerStyle := lipgloss.NewStyle().
- Width(60).
- Height(20).
- Align(lipgloss.Center, lipgloss.Center)
-
- return containerStyle.Render(form)
-}
-func (e *EditEndpointTab) OnFocus() tea.Cmd {
- e.inputs[0].Focus()
- e.focused = true
- return textinput.Blink
-}
-func (e *EditEndpointTab) OnBlur() tea.Cmd {
- e.inputs[e.focusedInput].Blur()
- e.focused = false
- return nil
-}
-
-func (e *EditEndpointTab) SetEditingEndpoint(msg messages.EditEndpointMsg) {
- e.inputs[0].SetValue(msg.Name)
- e.inputs[1].SetValue(msg.Method)
- e.inputs[2].SetValue(msg.URL)
- e.endpointID = msg.ID
-}
-
-func (e *EditEndpointTab) updateEndpoint(newName, newMethod, newURL string) (Tab, tea.Cmd) {
- ctx := global.GetAppContext()
- id, _ := strconv.ParseInt(e.endpointID, 10, 64)
- ctx.Endpoints.UpdateEndpoint(context.Background(), id, endpoints.EndpointData{
- Name: newName,
- Method: newMethod,
- URL: newURL,
- })
-
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 3}
- }
-}
diff --git a/internal/tabs/endpoints.go b/internal/tabs/endpoints.go
deleted file mode 100644
index 3a2da8f..0000000
--- a/internal/tabs/endpoints.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package tabs
-
-import (
- "context"
- "strconv"
-
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/endpoints"
- "github.com/maniac-en/req/internal/messages"
-)
-
-type EndpointsTab struct {
- name string
- globalState *global.State
- selectUI SelectInput
- loaded bool
- endpoints []endpoints.EndpointEntity
-}
-
-type endpointListOpts struct {
- options []OptionPair
-}
-
-func NewEndpointsTab(state *global.State) *EndpointsTab {
- return &EndpointsTab{
- name: "Endpoints",
- globalState: state,
- loaded: false,
- selectUI: NewSelectInput(),
- }
-}
-
-func (e *EndpointsTab) IsFiltering() bool {
- return e.selectUI.list.FilterState() == list.Filtering
-}
-
-func (e *EndpointsTab) Name() string {
- return e.name
-}
-
-func (e *EndpointsTab) Instructions() string {
- return "c - collections page • + - add endpoint • d - delete endpoint"
-}
-
-func (e *EndpointsTab) fetchEndpoints(collectionId string, limit, offset int) tea.Cmd {
- ctx := global.GetAppContext()
- collectionIdInt, err := strconv.ParseInt(collectionId, 10, 64)
- if err != nil {
- return func() tea.Msg {
- return endpointListOpts{}
- }
- }
- opts := []OptionPair{}
- endpoints, err := ctx.Endpoints.ListByCollection(context.Background(), collectionIdInt, limit, offset)
- e.endpoints = endpoints.Endpoints
- for _, endpoint := range endpoints.Endpoints {
- opts = append(opts, OptionPair{
- Label: endpoint.GetName(),
- Value: strconv.FormatInt(endpoint.GetID(), 10),
- })
- }
-
- return func() tea.Msg {
- return endpointListOpts{
- options: opts,
- }
- }
-}
-
-func (e *EndpointsTab) Init() tea.Cmd {
- e.selectUI.Focus()
- return e.selectUI.Init()
-}
-
-func (e *EndpointsTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
- var cmd tea.Cmd
-
- switch msg := msg.(type) {
- case endpointListOpts:
- e.selectUI.SetOptions(msg.options)
- e.loaded = true
- case tea.KeyMsg:
- switch msg.String() {
- case "c":
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 0}
- }
- case "+":
- return e, func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 4}
- }
- case "d":
- if selected := e.selectUI.GetSelected(); selected != "" {
- return e, e.deleteEndpoint(selected)
- }
- case "e":
- if selected := e.selectUI.GetSelected(); selected != "" {
- return e, e.editEndpoint(selected)
- }
- }
- default:
- e.selectUI, cmd = e.selectUI.Update(msg)
- }
-
- return e, cmd
-}
-
-func (e *EndpointsTab) View() string {
-
- if e.selectUI.IsLoading() {
- return e.selectUI.View()
- }
- selectContent := e.selectUI.View()
-
- style := lipgloss.NewStyle().
- PaddingRight(4)
-
- if !e.selectUI.IsLoading() && len(e.selectUI.list.Items()) > 0 {
- title := "\n\n\n\n\n\n\nSelect Endpoint:\n\n"
- return title + style.Render(selectContent)
- }
-
- return style.Render(selectContent)
-}
-
-func (e *EndpointsTab) OnFocus() tea.Cmd {
- e.selectUI.Focus()
- return e.fetchEndpoints(e.globalState.GetCurrentCollection(), 5, 0)
-}
-
-func (e *EndpointsTab) OnBlur() tea.Cmd {
- e.selectUI.Blur()
- return nil
-}
-
-func (e *EndpointsTab) deleteEndpoint(value string) tea.Cmd {
- ctx := global.GetAppContext()
- id, _ := strconv.ParseInt(value, 10, 64)
- err := ctx.Endpoints.Delete(context.Background(), id)
- if err != nil {
- return e.fetchEndpoints(e.globalState.GetCurrentCollection(), 5, 0)
- }
- for i, collection := range GlobalCollections {
- if collection.Value == value {
- GlobalCollections = append(GlobalCollections[:i], GlobalCollections[i+1:]...)
- break
- }
- }
- return e.fetchEndpoints(e.globalState.GetCurrentCollection(), 5, 0)
-}
-
-func (e *EndpointsTab) editEndpoint(value string) tea.Cmd {
- var endpoint endpoints.EndpointEntity
-
- for _, ep := range e.endpoints {
- selectedEpID, err := strconv.ParseInt(value, 10, 64)
- if err != nil {
- }
- if ep.GetID() == selectedEpID {
- endpoint = ep
- }
- }
-
- return tea.Batch(
- func() tea.Msg {
- return messages.EditEndpointMsg{
- Name: endpoint.GetName(),
- Method: endpoint.Method,
- URL: endpoint.Url,
- ID: value,
- }
- },
- func() tea.Msg {
- return messages.SwitchTabMsg{TabIndex: 5}
- },
- )
-}
diff --git a/internal/tabs/tab.go b/internal/tabs/tab.go
deleted file mode 100644
index d9e460c..0000000
--- a/internal/tabs/tab.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package tabs
-
-import (
- tea "github.com/charmbracelet/bubbletea"
-)
-
-var GlobalCollections = []OptionPair{}
-
-// this is what a tab is loosely defined as
-type Tab interface {
- Name() string
- Instructions() string
- Init() tea.Cmd
- Update(tea.Msg) (Tab, tea.Cmd)
- View() string
- OnFocus() tea.Cmd
- OnBlur() tea.Cmd
-}
diff --git a/internal/tui/app/context.go b/internal/tui/app/context.go
new file mode 100644
index 0000000..6e2cbb8
--- /dev/null
+++ b/internal/tui/app/context.go
@@ -0,0 +1,35 @@
+package app
+
+import (
+ "github.com/maniac-en/req/internal/backend/collections"
+ "github.com/maniac-en/req/internal/backend/endpoints"
+ "github.com/maniac-en/req/internal/backend/history"
+ "github.com/maniac-en/req/internal/backend/http"
+)
+
+type Context struct {
+ Collections *collections.CollectionsManager
+ Endpoints *endpoints.EndpointsManager
+ HTTP *http.HTTPManager
+ History *history.HistoryManager
+ DummyDataCreated bool
+}
+
+func NewContext(
+ collections *collections.CollectionsManager,
+ endpoints *endpoints.EndpointsManager,
+ httpManager *http.HTTPManager,
+ history *history.HistoryManager,
+) *Context {
+ return &Context{
+ 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
new file mode 100644
index 0000000..bcb82ac
--- /dev/null
+++ b/internal/tui/app/model.go
@@ -0,0 +1,186 @@
+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
+}
+
+func (m Model) Init() tea.Cmd {
+ return m.collectionsView.Init()
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd 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)
+ }
+ }
+ 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 m, cmd
+}
+
+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()
+ }
+}
diff --git a/internal/tui/components/collection_item.go b/internal/tui/components/collection_item.go
new file mode 100644
index 0000000..d476c2b
--- /dev/null
+++ b/internal/tui/components/collection_item.go
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 0000000..236d46a
--- /dev/null
+++ b/internal/tui/components/endpoint_item.go
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 0000000..80b3f45
--- /dev/null
+++ b/internal/tui/components/form.go
@@ -0,0 +1,144 @@
+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
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
new file mode 100644
index 0000000..a191e07
--- /dev/null
+++ b/internal/tui/components/layout.go
@@ -0,0 +1,146 @@
+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
new file mode 100644
index 0000000..10710f6
--- /dev/null
+++ b/internal/tui/components/paginated_list.go
@@ -0,0 +1,119 @@
+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
new file mode 100644
index 0000000..4049b1f
--- /dev/null
+++ b/internal/tui/components/text_input.go
@@ -0,0 +1,107 @@
+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
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/colors.go b/internal/tui/styles/colors.go
new file mode 100644
index 0000000..958f23a
--- /dev/null
+++ b/internal/tui/styles/colors.go
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..8f933ba
--- /dev/null
+++ b/internal/tui/styles/layout.go
@@ -0,0 +1,41 @@
+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
new file mode 100644
index 0000000..8d33600
--- /dev/null
+++ b/internal/tui/views/add_collection.go
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 0000000..c5a8576
--- /dev/null
+++ b/internal/tui/views/collections.go
@@ -0,0 +1,235 @@
+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
new file mode 100644
index 0000000..a6c7170
--- /dev/null
+++ b/internal/tui/views/edit_collection.go
@@ -0,0 +1,113 @@
+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
new file mode 100644
index 0000000..42de45d
--- /dev/null
+++ b/internal/tui/views/endpoint_sidebar.go
@@ -0,0 +1,179 @@
+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
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
new file mode 100644
index 0000000..a935162
--- /dev/null
+++ b/internal/tui/views/selected_collection.go
@@ -0,0 +1,276 @@
+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 bdc80aa..8d527cc 100644
--- a/main.go
+++ b/main.go
@@ -11,14 +11,14 @@ import (
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
- "github.com/maniac-en/req/global"
- "github.com/maniac-en/req/internal/app"
- "github.com/maniac-en/req/internal/collections"
- "github.com/maniac-en/req/internal/database"
- "github.com/maniac-en/req/internal/endpoints"
- "github.com/maniac-en/req/internal/history"
- "github.com/maniac-en/req/internal/http"
+ "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"
"github.com/maniac-en/req/internal/log"
+ "github.com/maniac-en/req/internal/tui/app"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
)
@@ -36,13 +36,7 @@ var (
DB *sql.DB
)
-type Config struct {
- DB *database.Queries
- Collections *collections.CollectionsManager
- Endpoints *endpoints.EndpointsManager
- HTTP *http.HTTPManager
- History *history.HistoryManager
-}
+var Version = "dev"
func initPaths() error {
// setup paths using OS-appropriate cache directory
@@ -141,27 +135,29 @@ func main() {
httpManager := http.NewHTTPManager()
historyManager := history.NewHistoryManager(db)
- config := &Config{
- DB: db,
- Collections: collectionsManager,
- Endpoints: endpointsManager,
- HTTP: httpManager,
- History: historyManager,
- }
- appContext := &global.AppContext{
- Collections: collectionsManager,
- Endpoints: endpointsManager,
- HTTP: httpManager,
- History: historyManager,
+ // create clean context for dependency injection
+ appContext := app.NewContext(
+ collectionsManager,
+ endpointsManager,
+ httpManager,
+ historyManager,
+ )
+
+ // 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)
}
- global.SetAppContext(appContext)
- log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging"})
- log.Debug("configuration loaded", "collections_manager", config.Collections != nil, "endpoints", config.Endpoints != nil, "database", config.DB != nil, "http_manager", config.HTTP != nil, "history_manager", config.History != nil)
+ 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")
// Entry point for UI
- program := tea.NewProgram(app.InitialModel(), tea.WithAltScreen())
+ program := tea.NewProgram(app.NewModel(appContext), tea.WithAltScreen())
if _, err := program.Run(); err != nil {
log.Fatal("Fatal error:", err)
}
diff --git a/pyssg.config.json b/pyssg.config.json
index 8928ff1..d45c411 100644
--- a/pyssg.config.json
+++ b/pyssg.config.json
@@ -1,7 +1,7 @@
{
"project": {
"name": "req",
- "title": "req - Terminal API Client"
+ "title": "Req - Test APIs with Terminal Velocity"
},
"paths": {
"content_dir": "web/content",
diff --git a/sqlc.yaml b/sqlc.yaml
index d861b73..1341f46 100644
--- a/sqlc.yaml
+++ b/sqlc.yaml
@@ -5,6 +5,6 @@ sql:
engine: "sqlite"
gen:
go:
- out: "internal/database"
+ out: "internal/backend/database"
emit_json_tags: true
emit_db_tags: true
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
diff --git a/web/content/index.md b/web/content/index.md
index 6fb2392..22c9d21 100644
--- a/web/content/index.md
+++ b/web/content/index.md
@@ -1,60 +1,57 @@
-# req - Terminal API Client
-
-> **Note**: This page is not up to date and serves as a boilerplate for the blog setup.
+# Req - Test APIs with Terminal Velocity
A terminal-based API client built for the [Boot.dev Hackathon 2025](https://blog.boot.dev/news/hackathon-2025/).
## Features
-- Terminal user interface
-- Request collections
-- Environment variables
-- Request history
+- Terminal user interface with beautiful TUI
+- Request collections and organization
+- Demo data generation with realistic APIs
+- Request builder with tabs for body, headers, query params
+- Production-ready logging system
## Tech Stack
The project uses:
-1. **Go** for core logic
-2. **Bubble Tea** for TUI
-3. **SQLite** for storage
-
-### Code Example
-
-```go
-func main() {
- fmt.Println("Hello, req!")
-}
-```
+1. **Go** for core logic and HTTP operations
+2. **Bubble Tea** for terminal user interface
+3. **SQLite** for file-based storage
+4. **SQLC** for type-safe database operations
+5. **Goose** for database migrations
## Installation
```bash
-go build -o req .
-./req
+go install github.com/maniac-en/req@v0.1.0
+req
```
-### Commands
+## What's Implemented
-- `req --help` - Show help
-- `req --verbose` - Verbose output
+- Collections CRUD operations (create, edit, delete, navigate)
+- Request builder interface with tabbed editing
+- Endpoint browsing with sidebar navigation
+- Demo data generation (JSONPlaceholder, ReqRes, HTTPBin APIs)
+- Beautiful warm color scheme with vim-like navigation
+- Pagination and real-time search filtering
-## Lists Test
+## Coming Soon
-**Unordered list:**
-- Item one
-- Item two
-- Item three
+- HTTP request execution (core feature)
+- Response viewer with syntax highlighting
+- Endpoint management (add/edit endpoints)
+- Environment variables support
+- Export/import functionality
-**Ordered list:**
-1. First step
-2. Second step
-3. Third step
+## Try It Out
-## Text Formatting
+**GitHub**: https://github.com/maniac-en/req
+**Installation**: `go install github.com/maniac-en/req@v0.1.0`
+**Usage**: Just run `req` in your terminal!
-This has **bold text**, _italic text_, and `inline code`.
+The app works completely offline with no external dependencies required.
---
-This blog is built with ❤️ using [pyssg](https://github.com/maniac-en/pyssg) - A guided learning project at [boot.dev](https://www.boot.dev/courses/build-static-site-generator)
+This blog is built with ❤️ using [pyssg](https://github.com/maniac-en/pyssg) - A guided learning project at [boot.dev](https://www.boot.dev/courses/build-static-site-generator)
\ No newline at end of file
diff --git a/web/req-demo.gif b/web/req-demo.gif
new file mode 100644
index 0000000..00d3535
Binary files /dev/null and b/web/req-demo.gif differ