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 +[![tests](https://github.com/maniac-en/req/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/maniac-en/req/actions/workflows/go.yml) ![GitHub repo](https://img.shields.io/badge/built%20at-Boot.dev%20Hackathon-blueviolet) + +## 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

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!")
-}
-

Installation

bash
-go build -o req .
-./req
-

Commands

Lists 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 Banner

Req - Test APIs with Terminal Velocity

A terminal-based API client built for the Boot.dev Hackathon 2025.

Features

Tech Stack

The project uses:

  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 install github.com/maniac-en/req@v0.1.0
+req
+
Demo GIF

What's Implemented

Coming Soon

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