diff --git a/.gitignore b/.gitignore index 3e0620d..72c82db 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ testdata/ .pyssg/ __pycache__/ *.pyc + +# build stuff +build/ + +# old tui +internal/old_tui/ diff --git a/db/queries/collections.sql b/db/queries/collections.sql index 6eb988a..4f7eedc 100644 --- a/db/queries/collections.sql +++ b/db/queries/collections.sql @@ -6,6 +6,10 @@ SELECT * FROM collections ORDER BY created_at DESC LIMIT ? OFFSET ?; +-- name: GetCollections :many +SELECT * FROM collections +ORDER BY created_at DESC; + -- name: CountCollections :one SELECT COUNT(*) FROM collections; diff --git a/db/queries/endpoints.sql b/db/queries/endpoints.sql index d379d3f..d672be7 100644 --- a/db/queries/endpoints.sql +++ b/db/queries/endpoints.sql @@ -16,6 +16,11 @@ RETURNING *; SELECT * FROM endpoints WHERE id = ? LIMIT 1; +-- name: ListEndpointsByCollection :many +SELECT * FROM endpoints +WHERE collection_id = ? +ORDER BY created_at DESC; + -- name: ListEndpointsPaginated :many SELECT * FROM endpoints WHERE collection_id = ? @@ -26,6 +31,19 @@ LIMIT ? OFFSET ?; SELECT COUNT(*) FROM endpoints WHERE collection_id = ?; +-- name: GetEndpointCountsByCollections :many +SELECT collection_id, COUNT(*) as count +FROM endpoints +GROUP BY collection_id; + +-- name: UpdateEndpointName :one +UPDATE endpoints +SET + name = ? +WHERE + id = ? +RETURNING *; + -- name: UpdateEndpoint :one UPDATE endpoints SET diff --git a/internal/backend/collections/manager.go b/internal/backend/collections/manager.go index 5c587d2..d9f07c4 100644 --- a/internal/backend/collections/manager.go +++ b/internal/backend/collections/manager.go @@ -96,12 +96,17 @@ func (c *CollectionsManager) Delete(ctx context.Context, id int64) error { } func (c *CollectionsManager) List(ctx context.Context) ([]CollectionEntity, error) { - log.Debug("listing all collections with default pagination") - paginated, err := c.ListPaginated(ctx, 50, 0) + collections, err := c.DB.GetCollections(ctx) if err != nil { return nil, err } - return paginated.Collections, nil + + entities := make([]CollectionEntity, len(collections)) + for i, collection := range collections { + entities[i] = CollectionEntity{Collection: collection} + } + + return entities, nil } func (c *CollectionsManager) ListPaginated(ctx context.Context, limit, offset int) (*PaginatedCollections, error) { diff --git a/internal/backend/collections/models.go b/internal/backend/collections/models.go index 94b46db..72778cc 100644 --- a/internal/backend/collections/models.go +++ b/internal/backend/collections/models.go @@ -19,6 +19,7 @@ func (c CollectionEntity) GetName() string { return c.Name } + func (c CollectionEntity) GetCreatedAt() time.Time { return crud.ParseTimestamp(c.CreatedAt) } diff --git a/internal/backend/database/collections.sql.go b/internal/backend/database/collections.sql.go index 124ec33..799c0c3 100644 --- a/internal/backend/database/collections.sql.go +++ b/internal/backend/database/collections.sql.go @@ -63,6 +63,39 @@ func (q *Queries) GetCollection(ctx context.Context, id int64) (Collection, erro return i, err } +const getCollections = `-- name: GetCollections :many +SELECT id, name, created_at, updated_at FROM collections +ORDER BY created_at DESC +` + +func (q *Queries) GetCollections(ctx context.Context) ([]Collection, error) { + rows, err := q.db.QueryContext(ctx, getCollections) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Collection + for rows.Next() { + var i Collection + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getCollectionsPaginated = `-- name: GetCollectionsPaginated :many SELECT id, name, created_at, updated_at FROM collections ORDER BY created_at DESC diff --git a/internal/backend/database/endpoints.sql.go b/internal/backend/database/endpoints.sql.go index 3c9433a..0b3ebd3 100644 --- a/internal/backend/database/endpoints.sql.go +++ b/internal/backend/database/endpoints.sql.go @@ -105,6 +105,80 @@ func (q *Queries) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { return i, err } +const getEndpointCountsByCollections = `-- name: GetEndpointCountsByCollections :many +SELECT collection_id, COUNT(*) as count +FROM endpoints +GROUP BY collection_id +` + +type GetEndpointCountsByCollectionsRow struct { + CollectionID int64 `db:"collection_id" json:"collection_id"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) GetEndpointCountsByCollections(ctx context.Context) ([]GetEndpointCountsByCollectionsRow, error) { + rows, err := q.db.QueryContext(ctx, getEndpointCountsByCollections) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetEndpointCountsByCollectionsRow + for rows.Next() { + var i GetEndpointCountsByCollectionsRow + if err := rows.Scan(&i.CollectionID, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEndpointsByCollection = `-- name: ListEndpointsByCollection :many +SELECT id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at FROM endpoints +WHERE collection_id = ? +ORDER BY created_at DESC +` + +func (q *Queries) ListEndpointsByCollection(ctx context.Context, collectionID int64) ([]Endpoint, error) { + rows, err := q.db.QueryContext(ctx, listEndpointsByCollection, collectionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Endpoint + for rows.Next() { + var i Endpoint + if err := rows.Scan( + &i.ID, + &i.CollectionID, + &i.Name, + &i.Method, + &i.Url, + &i.Headers, + &i.QueryParams, + &i.RequestBody, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listEndpointsPaginated = `-- name: ListEndpointsPaginated :many SELECT id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at FROM endpoints WHERE collection_id = ? @@ -201,3 +275,35 @@ func (q *Queries) UpdateEndpoint(ctx context.Context, arg UpdateEndpointParams) ) return i, err } + +const updateEndpointName = `-- name: UpdateEndpointName :one +UPDATE endpoints +SET + name = ? +WHERE + id = ? +RETURNING id, collection_id, name, method, url, headers, query_params, request_body, created_at, updated_at +` + +type UpdateEndpointNameParams struct { + Name string `db:"name" json:"name"` + ID int64 `db:"id" json:"id"` +} + +func (q *Queries) UpdateEndpointName(ctx context.Context, arg UpdateEndpointNameParams) (Endpoint, error) { + row := q.db.QueryRowContext(ctx, updateEndpointName, arg.Name, arg.ID) + var i Endpoint + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.Name, + &i.Method, + &i.Url, + &i.Headers, + &i.QueryParams, + &i.RequestBody, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/backend/endpoints/manager.go b/internal/backend/endpoints/manager.go index 196b15f..7c4470a 100644 --- a/internal/backend/endpoints/manager.go +++ b/internal/backend/endpoints/manager.go @@ -64,7 +64,7 @@ func (e *EndpointsManager) List(ctx context.Context) ([]EndpointEntity, error) { return nil, fmt.Errorf("use ListByCollection to list endpoints for a specific collection") } -func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID int64, limit, offset int) (*PaginatedEndpoints, error) { +func (e *EndpointsManager) ListByCollectionByPage(ctx context.Context, collectionID int64, limit, offset int) (*PaginatedEndpoints, error) { if err := crud.ValidateID(collectionID); err != nil { log.Warn("endpoint list failed collection validation", "collection_id", collectionID) return nil, crud.ErrInvalidInput @@ -103,6 +103,29 @@ func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID in return result, nil } +func (e *EndpointsManager) ListByCollection(ctx context.Context, collectionID int64) ([]EndpointEntity, error) { + if err := crud.ValidateID(collectionID); err != nil { + log.Warn("endpoint list failed collection validation", "collection_id", collectionID) + return nil, crud.ErrInvalidInput + } + + log.Debug("listing endpoints", "collection_id", collectionID, "limit") + + endpoints, err := e.DB.ListEndpointsByCollection(context.Background(), collectionID) + if err != nil && err != sql.ErrNoRows { + log.Warn("error occured while fetching endpoints", "collection_id", collectionID) + return nil, err + } + + entities := make([]EndpointEntity, len(endpoints)) + for i, endpoint := range endpoints { + entities[i] = EndpointEntity{Endpoint: endpoint} + } + + log.Info("retrieved endpoints", "collection_id", collectionID, "count") + return entities, nil +} + func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData) (EndpointEntity, error) { if err := crud.ValidateID(data.CollectionID); err != nil { log.Warn("endpoint creation failed collection validation", "collection_id", data.CollectionID) @@ -112,8 +135,8 @@ func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData log.Warn("endpoint creation failed name validation", "name", data.Name) return EndpointEntity{}, crud.ErrInvalidInput } - if data.Method == "" || data.URL == "" { - log.Warn("endpoint creation failed - method and URL required", "method", data.Method, "url", data.URL) + if data.Method == "" { + log.Warn("endpoint creation failed - method required", "method", data.Method) return EndpointEntity{}, crud.ErrInvalidInput } @@ -151,6 +174,37 @@ func (e *EndpointsManager) CreateEndpoint(ctx context.Context, data EndpointData return EndpointEntity{Endpoint: endpoint}, nil } +func (e *EndpointsManager) UpdateEndpointName(ctx context.Context, id int64, name string) (EndpointEntity, error) { + if err := crud.ValidateID(id); err != nil { + log.Warn("endpoint update failed ID validation", "id", id) + return EndpointEntity{}, crud.ErrInvalidInput + } + + if err := crud.ValidateName(name); err != nil { + log.Warn("endpoint update failed name validation", "name", name) + return EndpointEntity{}, crud.ErrInvalidInput + } + + log.Debug("updating endpoint name", "id", id, "name", name) + + endpoint, err := e.DB.UpdateEndpointName(ctx, database.UpdateEndpointNameParams{ + Name: name, + ID: id, + }) + + if err != nil { + if err == sql.ErrNoRows { + log.Debug("endpoint not found for update", "id", id) + return EndpointEntity{}, crud.ErrNotFound + } + log.Error("failed to update endpoint", "id", id, "name", name, "error", err) + return EndpointEntity{}, err + } + + log.Info("updated endpoint", "id", endpoint.ID, "name", endpoint.Name) + return EndpointEntity{Endpoint: endpoint}, nil +} + func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data EndpointData) (EndpointEntity, error) { if err := crud.ValidateID(id); err != nil { log.Warn("endpoint update failed ID validation", "id", id) @@ -160,8 +214,8 @@ func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data En log.Warn("endpoint update failed name validation", "name", data.Name) return EndpointEntity{}, crud.ErrInvalidInput } - if data.Method == "" || data.URL == "" { - log.Warn("endpoint update failed - method and URL required", "method", data.Method, "url", data.URL) + if data.Method == "" { + log.Warn("endpoint update failed - method required", "method", data.Method) return EndpointEntity{}, crud.ErrInvalidInput } @@ -202,3 +256,12 @@ func (e *EndpointsManager) UpdateEndpoint(ctx context.Context, id int64, data En log.Info("updated endpoint", "id", endpoint.ID, "name", endpoint.Name) return EndpointEntity{Endpoint: endpoint}, nil } + +func (e *EndpointsManager) GetCountsByCollections(ctx context.Context) ([]database.GetEndpointCountsByCollectionsRow, error) { + counts, err := e.DB.GetEndpointCountsByCollections(ctx) + if err != nil { + log.Error("failed to get endpoint counts", "error", err) + return nil, err + } + return counts, nil +} diff --git a/internal/backend/endpoints/manager_test.go b/internal/backend/endpoints/manager_test.go index d8aa701..303990b 100644 --- a/internal/backend/endpoints/manager_test.go +++ b/internal/backend/endpoints/manager_test.go @@ -177,9 +177,12 @@ func TestCreateEndpoint(t *testing.T) { URL: "", } - _, err := manager.CreateEndpoint(ctx, data) - if err != crud.ErrInvalidInput { - t.Errorf("Expected ErrInvalidInput, got %v", err) + endpoint, err := manager.CreateEndpoint(ctx, data) + if err != nil { + t.Errorf("Expected empty URL to be allowed, got error: %v", err) + } + if endpoint.Url != "" { + t.Errorf("Expected empty URL to be preserved, got %s", endpoint.Url) } }) } @@ -282,7 +285,7 @@ func TestListByCollection(t *testing.T) { } t.Run("Valid pagination", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 0) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 0) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -308,7 +311,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Second page", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 2) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 2) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -328,7 +331,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Last page", func(t *testing.T) { - result, err := manager.ListByCollection(ctx, collectionID, 2, 4) + result, err := manager.ListByCollectionByPage(ctx, collectionID, 2, 4) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } @@ -348,7 +351,7 @@ func TestListByCollection(t *testing.T) { }) t.Run("Invalid collection ID", func(t *testing.T) { - _, err := manager.ListByCollection(ctx, -1, 10, 0) + _, err := manager.ListByCollectionByPage(ctx, -1, 10, 0) if err != crud.ErrInvalidInput { t.Errorf("Expected ErrInvalidInput, got %v", err) } @@ -356,7 +359,7 @@ func TestListByCollection(t *testing.T) { t.Run("Empty collection", func(t *testing.T) { emptyCollectionID := testutils.CreateTestCollection(t, db, "Empty Collection") - result, err := manager.ListByCollection(ctx, emptyCollectionID, 10, 0) + result, err := manager.ListByCollectionByPage(ctx, emptyCollectionID, 10, 0) if err != nil { t.Fatalf("ListByCollection failed: %v", err) } diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go index 5c46f69..ca103f0 100644 --- a/internal/tui/app/model.go +++ b/internal/tui/app/model.go @@ -1,186 +1,200 @@ package app import ( - "context" + "sort" + "strings" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/maniac-en/req/internal/log" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" + "github.com/maniac-en/req/internal/tui/styles" "github.com/maniac-en/req/internal/tui/views" ) -type ViewMode int +type ViewName string const ( - CollectionsViewMode ViewMode = iota - AddCollectionViewMode - EditCollectionViewMode - SelectedCollectionViewMode + Collections ViewName = "collections" + Endpoints ViewName = "endpoints" ) -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 +type Heading struct { + name string + order int } -func NewModel(ctx *Context) Model { - collectionsView := views.NewCollectionsView(ctx.Collections) - if ctx.DummyDataCreated { - collectionsView.SetDummyDataNotification(true) - } - - m := Model{ - ctx: ctx, - mode: CollectionsViewMode, - collectionsView: collectionsView, - addCollectionView: views.NewAddCollectionView(ctx.Collections), - } - return m +type AppModel struct { + ctx *Context + width int + height int + Views map[ViewName]views.ViewInterface + focusedView ViewName + keys []key.Binding + help help.Model + errorMsg string } -func (m Model) Init() tea.Cmd { - return m.collectionsView.Init() +func (a AppModel) Init() tea.Cmd { + return nil } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (a AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - + var cmds []tea.Cmd switch msg := msg.(type) { - case tea.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: + a.height = msg.Height + a.width = msg.Width + for key, _ := range a.Views { + a.Views[key], cmd = a.Views[key].Update(tea.WindowSizeMsg{Height: a.AvailableHeight(), Width: msg.Width}) + cmds = append(cmds, cmd) + } + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) + case messages.ChooseItem[optionsProvider.Option]: + switch msg.Source { + case "collections": + return a, func() tea.Msg { + return messages.NavigateToView{ + ViewName: string(Endpoints), + Data: msg.Item, } } } - 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) + case messages.NavigateToView: + a.Views[a.focusedView].OnBlur() + + if msg.Data != nil { + err := a.Views[ViewName(msg.ViewName)].SetState(msg.Data) + if err != nil { + log.Error("failed to set view state during navigation", "target_view", msg.ViewName, "error", err) + return a, nil } - return m, m.collectionsView.Init() } - if m.mode == CollectionsViewMode { - m.collectionsView, _ = m.collectionsView.Update(msg) - } - case views.BackToCollectionsMsg: - m.mode = CollectionsViewMode - if m.width > 0 && m.height > 0 { - m.collectionsView = views.NewCollectionsViewWithSize(m.ctx.Collections, m.width, m.height) - if m.ctx.DummyDataCreated { - m.collectionsView.SetDummyDataNotification(true) + + a.focusedView = ViewName(msg.ViewName) + a.Views[a.focusedView].OnFocus() + return a, nil + case messages.ShowError: + log.Error("user operation failed", "error", msg.Message) + a.errorMsg = msg.Message + return a, nil + case tea.KeyMsg: + a.errorMsg = "" + switch { + case key.Matches(msg, keybinds.Keys.Quit): + return a, tea.Quit + case key.Matches(msg, keybinds.Keys.Back): + if a.focusedView == Endpoints { + return a, func() tea.Msg { + return messages.NavigateToView{ + ViewName: string(Collections), + Data: nil, + } + } } } - 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) + a.Views[a.focusedView], cmd = a.Views[a.focusedView].Update(msg) + cmds = append(cmds, cmd) + + return a, tea.Batch(cmds...) +} + +func (a AppModel) View() string { + footer := a.Footer() + header := a.Header() + view := a.Views[a.focusedView].View() + help := a.Help() + + if a.errorMsg != "" { + errorBar := styles.ErrorBarStyle.Width(a.width).Render("Error: " + a.errorMsg) + return lipgloss.JoinVertical(lipgloss.Top, header, view, errorBar, help, footer) + } + + return lipgloss.JoinVertical(lipgloss.Top, header, view, help, footer) +} + +func (a AppModel) Help() string { + viewHelp := a.Views[a.focusedView].Help() + + var appHelp []key.Binding + appHelp = append(appHelp, a.keys...) + + if a.focusedView == Endpoints { + appHelp = append(appHelp, keybinds.Keys.Back) + } + + allHelp := append(viewHelp, appHelp...) + helpStruct := keybinds.Help{ + Keys: allHelp, + } + return styles.HelpStyle.Render(a.help.View(helpStruct)) +} + +func (a *AppModel) AvailableHeight() int { + footer := a.Footer() + header := a.Header() + help := a.Help() + return a.height - lipgloss.Height(header) - lipgloss.Height(footer) - lipgloss.Height(help) +} + +func (a AppModel) Header() string { + var b strings.Builder + + // INFO: this might be a bit messy, could be a nice idea to look into OrderedMaps maybe? + views := []Heading{} + for key := range a.Views { + views = append(views, Heading{ + name: a.Views[key].Name(), + order: a.Views[key].Order(), + }) + } + sort.Slice(views, func(i, j int) bool { + return views[i].order < views[j].order + }) + + for _, item := range views { + if item.name == a.Views[a.focusedView].Name() { + b.WriteString(styles.TabHeadingActive.Render(item.name)) + } else { + b.WriteString(styles.TabHeadingInactive.Render(item.name)) + } } - return m, cmd + b.WriteString(styles.TabHeadingInactive.Render("")) + + return b.String() } -func (m Model) View() string { - switch m.mode { - case CollectionsViewMode: - return m.collectionsView.View() - case AddCollectionViewMode: - return m.addCollectionView.View() - case EditCollectionViewMode: - return m.editCollectionView.View() - case SelectedCollectionViewMode: - return m.selectedCollectionView.View() - default: - return m.collectionsView.View() +func (a AppModel) Footer() string { + name := styles.ApplyGradientToFooter("REQ") + footerText := styles.FooterSegmentStyle.Render(a.Views[a.focusedView].GetFooterSegment()) + version := styles.FooterVersionStyle.Width(a.width - lipgloss.Width(name) - lipgloss.Width(footerText)).Render("v0.1.0-alpha.2") + return lipgloss.JoinHorizontal(lipgloss.Left, name, footerText, version) +} + +func NewAppModel(ctx *Context) AppModel { + appKeybinds := []key.Binding{ + keybinds.Keys.Quit, + } + + model := AppModel{ + focusedView: Collections, + ctx: ctx, + help: help.New(), + keys: appKeybinds, } + model.Views = map[ViewName]views.ViewInterface{ + Collections: views.NewCollectionsView(model.ctx.Collections, model.ctx.Endpoints, 1), + Endpoints: views.NewEndpointsView(model.ctx.Endpoints, 2), + } + return model } + diff --git a/internal/tui/components/OptionsProvider/component.go b/internal/tui/components/OptionsProvider/component.go new file mode 100644 index 0000000..2bf2aa0 --- /dev/null +++ b/internal/tui/components/OptionsProvider/component.go @@ -0,0 +1,227 @@ +package optionsProvider + +import ( + "context" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/maniac-en/req/internal/log" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" +) + +type focusedComp string + +const ( + listComponent = "list" + textComponent = "text" +) + +type OptionsProvider[T, U any] struct { + list list.Model + input OptionsInput + onSelectAction tea.Msg + keys *keybinds.ListKeyMap + width int + height int + focused focusedComp + getItems func(context.Context) ([]T, error) + itemMapper func([]T) []list.Item + source string +} + +type Option struct { + Name string + ID int64 + Subtext string +} + +func (o Option) Title() string { return o.Name } +func (o Option) Description() string { return o.Subtext } +func (o Option) Value() int64 { return o.ID } +func (o Option) FilterValue() string { return o.Name } + +func (o OptionsProvider[T, U]) Init() tea.Cmd { + return nil +} + +func (o OptionsProvider[T, U]) Update(msg tea.Msg) (OptionsProvider[T, U], tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + o.height = msg.Height + o.width = msg.Width + o.list.SetSize(o.list.Width(), o.height) + case tea.KeyMsg: + switch o.focused { + case listComponent: + if !o.IsFiltering() { + switch { + case key.Matches(msg, o.keys.AddItem): + o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) + o.input.OnFocus() + o.focused = textComponent + return o, tea.Batch(cmds...) + case key.Matches(msg, o.keys.DeleteItem): + return o, func() tea.Msg { return messages.DeleteItem{ItemID: int64(o.GetSelected().ID)} } + case key.Matches(msg, o.keys.Choose): + return o, func() tea.Msg { + return messages.ChooseItem[Option]{ + Item: o.GetSelected(), + Source: o.source, + } + } + case key.Matches(msg, o.keys.EditItem): + o.list.SetSize(o.list.Width(), o.height-lipgloss.Height(o.input.View())) + o.input.SetInput(o.GetSelected().Name) + o.input.OnFocus(o.GetSelected().ID) + o.focused = textComponent + return o, tea.Batch(cmds...) + } + } + } + case messages.ItemAdded, messages.ItemEdited: + o.input.OnBlur() + o.focused = listComponent + o.list.SetSize(o.list.Width(), o.height) + o.RefreshItems() + case messages.DeactivateView: + o.input.OnBlur() + o.focused = listComponent + o.list.SetSize(o.list.Width(), o.height) + } + switch o.focused { + case listComponent: + o.list, cmd = o.list.Update(msg) + case textComponent: + o.input, cmd = o.input.Update(msg) + } + cmds = append(cmds, cmd) + return o, tea.Batch(cmds...) +} + +func (o OptionsProvider[T, U]) View() string { + if o.focused == textComponent { + return lipgloss.JoinVertical(lipgloss.Left, o.list.View(), o.input.View()) + } + return o.list.View() +} + +func (o *OptionsProvider[T, U]) OnFocus() { +} + +func (o OptionsProvider[T, U]) OnBlur() { + +} + +func (o OptionsProvider[T, U]) GetSelected() Option { + if o.IsFiltering() { + return Option{ + Name: "Filtering....", + ID: -1, + Subtext: "", + } + } + return o.list.SelectedItem().(Option) +} + +func (o OptionsProvider[T, U]) IsFiltering() bool { + return o.list.FilterState() == list.Filtering +} + +func (o *OptionsProvider[T, U]) RefreshItems() { + newItems, err := o.getItems(context.Background()) + if err != nil { + log.Warn("Fetching items failed") + return + } + o.list.SetItems(o.itemMapper(newItems)) +} + +func (o *OptionsProvider[T, U]) Help() []key.Binding { + var binds []key.Binding + switch o.focused { + case listComponent: + if o.IsFiltering() { + binds = []key.Binding{ + o.keys.AcceptWhileFiltering, + o.keys.CancelWhileFiltering, + o.keys.ClearFilter, + } + } else { + binds = []key.Binding{ + o.keys.CursorUp, + o.keys.CursorDown, + o.keys.NextPage, + o.keys.PrevPage, + o.keys.Filter, + o.keys.AddItem, + o.keys.EditItem, + o.keys.DeleteItem, + } + } + case textComponent: + binds = o.input.Help() + default: + binds = []key.Binding{} + } + return binds +} + +func initList[T, U any](config *ListConfig[T, U]) list.Model { + + rawItems, err := config.GetItemsFunc(context.Background()) + + if err != nil { + rawItems = []T{} + } + + items := config.ItemMapper(rawItems) + + list := list.New(items, config.Delegate, 30, 30) + + // list configuration + list.SetFilteringEnabled(config.FilteringEnabled) + list.SetShowStatusBar(config.ShowStatusBar) + list.SetShowPagination(config.ShowPagination) + list.SetShowHelp(config.ShowHelp) + list.SetShowTitle(config.ShowTitle) + list.KeyMap = config.KeyMap + + return list +} + +func (o *OptionsProvider[T, U]) SetGetItemsFunc(getItems func(context.Context) ([]T, error)) { + o.getItems = getItems + items, err := getItems(context.Background()) + if err != nil { + log.Error("error fetching items") + } + o.list.SetItems(o.itemMapper(items)) +} + +func NewOptionsProvider[T, U any](config *ListConfig[T, U]) OptionsProvider[T, U] { + inputConfig := InputConfig{ + CharLimit: 100, + Placeholder: "Add A New Collection...", + Width: 22, + Prompt: "", + KeyMap: InputKeyMaps{ + Accept: config.AdditionalKeymaps.Accept, + Back: config.AdditionalKeymaps.Back, + }, + } + + return OptionsProvider[T, U]{ + list: initList(config), + focused: listComponent, + input: NewOptionsInput(&inputConfig), + getItems: config.GetItemsFunc, + itemMapper: config.ItemMapper, + keys: config.AdditionalKeymaps, + source: config.Source, + } +} diff --git a/internal/tui/components/OptionsProvider/config.go b/internal/tui/components/OptionsProvider/config.go new file mode 100644 index 0000000..552681a --- /dev/null +++ b/internal/tui/components/OptionsProvider/config.go @@ -0,0 +1,46 @@ +package optionsProvider + +import ( + "context" + + "github.com/charmbracelet/bubbles/key" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/tui/keybinds" +) + +type ListConfig[T, U any] struct { + OnSelectAction tea.Msg + + ShowPagination bool + ShowStatusBar bool + ShowHelp bool + ShowTitle bool + Width, Height int + + FilteringEnabled bool + + Delegate list.ItemDelegate + KeyMap list.KeyMap + AdditionalKeymaps *keybinds.ListKeyMap + + ItemMapper func([]T) []list.Item + + GetItemsFunc func(context.Context) ([]T, error) + Source string + // Style lipgloss.Style +} + +type InputConfig struct { + Prompt string + Placeholder string + CharLimit int + Width int + KeyMap InputKeyMaps +} + +type InputKeyMaps struct { + Accept key.Binding + Back key.Binding +} diff --git a/internal/tui/components/OptionsProvider/input.go b/internal/tui/components/OptionsProvider/input.go new file mode 100644 index 0000000..2a8a84b --- /dev/null +++ b/internal/tui/components/OptionsProvider/input.go @@ -0,0 +1,87 @@ +package optionsProvider + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/tui/messages" + "github.com/maniac-en/req/internal/tui/styles" +) + +type OptionsInput struct { + input textinput.Model + height int + width int + editId int64 + keys InputKeyMaps +} + +func NewOptionsInput(config *InputConfig) OptionsInput { + input := textinput.New() + input.CharLimit = config.CharLimit + input.Placeholder = config.Placeholder + input.Width = config.Width + input.TextStyle = styles.InputStyle + input.Prompt = config.Prompt + + return OptionsInput{ + input: input, + editId: -1, + keys: config.KeyMap, + } +} + +func (i OptionsInput) Init() tea.Cmd { + return nil +} + +func (i OptionsInput) Update(msg tea.Msg) (OptionsInput, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, i.keys.Accept): + itemName := i.input.Value() + i.input.SetValue("") + if i.editId == -1 { + return i, func() tea.Msg { return messages.ItemAdded{Item: itemName} } + } + return i, func() tea.Msg { return messages.ItemEdited{Item: itemName, ItemID: i.editId} } + case key.Matches(msg, i.keys.Back): + return i, func() tea.Msg { return messages.DeactivateView{} } + } + } + + i.input, cmd = i.input.Update(msg) + cmds = append(cmds, cmd) + + return i, tea.Batch(cmds...) +} + +func (i OptionsInput) View() string { + return styles.InputStyle.Render(i.input.View()) +} + +func (i OptionsInput) Help() []key.Binding { + return []key.Binding{ + i.keys.Accept, + i.keys.Back, + } +} + +func (i *OptionsInput) SetInput(text string) { + i.input.SetValue(text) +} + +func (i *OptionsInput) OnFocus(id ...int64) { + if len(id) > 0 { + i.editId = id[0] + } + i.input.Focus() +} + +func (i *OptionsInput) OnBlur() { + i.editId = -1 + i.input.Blur() +} diff --git a/internal/tui/components/collection_item.go b/internal/tui/components/collection_item.go deleted file mode 100644 index d476c2b..0000000 --- a/internal/tui/components/collection_item.go +++ /dev/null @@ -1,36 +0,0 @@ -package components - -import ( - "fmt" - "strconv" - - "github.com/maniac-en/req/internal/backend/collections" -) - -type CollectionItem struct { - collection collections.CollectionEntity -} - -func NewCollectionItem(collection collections.CollectionEntity) CollectionItem { - return CollectionItem{collection: collection} -} - -func (i CollectionItem) FilterValue() string { - return i.collection.Name -} - -func (i CollectionItem) GetID() string { - return strconv.FormatInt(i.collection.ID, 10) -} - -func (i CollectionItem) GetTitle() string { - return i.collection.Name -} - -func (i CollectionItem) GetDescription() string { - return fmt.Sprintf("ID: %d", i.collection.ID) -} - -func (i CollectionItem) GetCollection() collections.CollectionEntity { - return i.collection -} diff --git a/internal/tui/components/endpoint_item.go b/internal/tui/components/endpoint_item.go deleted file mode 100644 index 236d46a..0000000 --- a/internal/tui/components/endpoint_item.go +++ /dev/null @@ -1,41 +0,0 @@ -package components - -import ( - "fmt" - - "github.com/maniac-en/req/internal/backend/endpoints" -) - -type EndpointItem struct { - endpoint endpoints.EndpointEntity -} - -func NewEndpointItem(endpoint endpoints.EndpointEntity) EndpointItem { - return EndpointItem{ - endpoint: endpoint, - } -} - -func (i EndpointItem) FilterValue() string { - return i.endpoint.Name -} - -func (i EndpointItem) GetID() string { - return fmt.Sprintf("%d", i.endpoint.ID) -} - -func (i EndpointItem) GetTitle() string { - return fmt.Sprintf("%s %s", i.endpoint.Method, i.endpoint.Name) -} - -func (i EndpointItem) GetDescription() string { - return i.endpoint.Url -} - -func (i EndpointItem) Title() string { - return i.GetTitle() -} - -func (i EndpointItem) Description() string { - return i.GetDescription() -} diff --git a/internal/tui/components/form.go b/internal/tui/components/form.go deleted file mode 100644 index 80b3f45..0000000 --- a/internal/tui/components/form.go +++ /dev/null @@ -1,144 +0,0 @@ -package components - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Form struct { - inputs []TextInput - focusIndex int - width int - height int - title string - submitText string - cancelText string -} - -func NewForm(title string, inputs []TextInput) Form { - if len(inputs) > 0 { - inputs[0].Focus() - } - - return Form{ - inputs: inputs, - focusIndex: 0, - title: title, - submitText: "Submit", - cancelText: "Cancel", - } -} - -func (f *Form) SetSize(width, height int) { - f.width = width - f.height = height - - for i := range f.inputs { - f.inputs[i].SetWidth(width - 4) - } -} - -func (f *Form) SetSubmitText(text string) { - f.submitText = text -} - -func (f *Form) SetCancelText(text string) { - f.cancelText = text -} - -func (f Form) GetInput(index int) *TextInput { - if index >= 0 && index < len(f.inputs) { - return &f.inputs[index] - } - return nil -} - -func (f Form) GetValues() []string { - values := make([]string, len(f.inputs)) - for i, input := range f.inputs { - values[i] = input.Value() - } - return values -} - -func (f *Form) Clear() { - for i := range f.inputs { - f.inputs[i].Clear() - } -} - -func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab", "down": - f.nextInput() - case "shift+tab", "up": - f.prevInput() - } - } - - if f.focusIndex >= 0 && f.focusIndex < len(f.inputs) { - f.inputs[f.focusIndex], cmd = f.inputs[f.focusIndex].Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - } - - return f, tea.Batch(cmds...) -} - -func (f *Form) nextInput() { - if len(f.inputs) == 0 { - return - } - - f.inputs[f.focusIndex].Blur() - f.focusIndex = (f.focusIndex + 1) % len(f.inputs) - f.inputs[f.focusIndex].Focus() -} - -func (f *Form) prevInput() { - if len(f.inputs) == 0 { - return - } - - f.inputs[f.focusIndex].Blur() - f.focusIndex-- - if f.focusIndex < 0 { - f.focusIndex = len(f.inputs) - 1 - } - f.inputs[f.focusIndex].Focus() -} - -func (f Form) View() string { - var content []string - - for _, input := range f.inputs { - content = append(content, input.View()) - } - - content = append(content, "") - - buttonStyle := styles.ListItemStyle.Copy(). - Padding(0, 2). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - - buttons := lipgloss.JoinHorizontal( - lipgloss.Top, - buttonStyle.Render(f.submitText+" (enter)"), - " ", - buttonStyle.Copy(). - Background(styles.TextSecondary). - Render(f.cancelText+" (esc)"), - ) - content = append(content, buttons) - - return lipgloss.JoinVertical(lipgloss.Left, content...) -} diff --git a/internal/tui/components/keyvalue_editor.go b/internal/tui/components/keyvalue_editor.go deleted file mode 100644 index af5bfeb..0000000 --- a/internal/tui/components/keyvalue_editor.go +++ /dev/null @@ -1,260 +0,0 @@ -package components - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type KeyValuePair struct { - Key string - Value string - Enabled bool -} - -type KeyValueEditor struct { - label string - pairs []KeyValuePair - width int - height int - focused bool - focusIndex int // Which pair is focused - fieldIndex int // 0=key, 1=value, 2=enabled -} - -func NewKeyValueEditor(label string) KeyValueEditor { - return KeyValueEditor{ - label: label, - pairs: []KeyValuePair{{"", "", true}}, // Start with one empty pair - width: 50, - height: 6, - focused: false, - focusIndex: 0, - fieldIndex: 0, - } -} - -func (kv *KeyValueEditor) SetSize(width, height int) { - kv.width = width - kv.height = height -} - -func (kv *KeyValueEditor) Focus() { - kv.focused = true -} - -func (kv *KeyValueEditor) Blur() { - kv.focused = false -} - -func (kv KeyValueEditor) Focused() bool { - return kv.focused -} - -func (kv *KeyValueEditor) SetPairs(pairs []KeyValuePair) { - if len(pairs) == 0 { - kv.pairs = []KeyValuePair{{"", "", true}} - } else { - kv.pairs = pairs - } - // Ensure focus is within bounds - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = len(kv.pairs) - 1 - } -} - -func (kv KeyValueEditor) GetPairs() []KeyValuePair { - return kv.pairs -} - -func (kv KeyValueEditor) GetEnabledPairsAsMap() map[string]string { - result := make(map[string]string) - for _, pair := range kv.pairs { - if pair.Enabled && pair.Key != "" { - result[pair.Key] = pair.Value - } - } - return result -} - -func (kv KeyValueEditor) Update(msg tea.Msg) (KeyValueEditor, tea.Cmd) { - if !kv.focused { - return kv, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab": - // Move to next field - kv.fieldIndex++ - if kv.fieldIndex > 2 { // key, value, enabled - kv.fieldIndex = 0 - kv.focusIndex++ - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = 0 - } - } - case "shift+tab": - // Move to previous field - kv.fieldIndex-- - if kv.fieldIndex < 0 { - kv.fieldIndex = 2 - kv.focusIndex-- - if kv.focusIndex < 0 { - kv.focusIndex = len(kv.pairs) - 1 - } - } - case "up": - if kv.focusIndex > 0 { - kv.focusIndex-- - } - case "down": - if kv.focusIndex < len(kv.pairs)-1 { - kv.focusIndex++ - } - case "ctrl+n": - // Add new pair - kv.pairs = append(kv.pairs, KeyValuePair{"", "", true}) - case "ctrl+d": - // Delete current pair (but keep at least one) - if len(kv.pairs) > 1 { - kv.pairs = append(kv.pairs[:kv.focusIndex], kv.pairs[kv.focusIndex+1:]...) - if kv.focusIndex >= len(kv.pairs) { - kv.focusIndex = len(kv.pairs) - 1 - } - } - case " ": - // Toggle enabled state when on enabled field - if kv.fieldIndex == 2 { - kv.pairs[kv.focusIndex].Enabled = !kv.pairs[kv.focusIndex].Enabled - } - case "backspace": - // Delete character from current field - if kv.fieldIndex == 0 && len(kv.pairs[kv.focusIndex].Key) > 0 { - kv.pairs[kv.focusIndex].Key = kv.pairs[kv.focusIndex].Key[:len(kv.pairs[kv.focusIndex].Key)-1] - } else if kv.fieldIndex == 1 && len(kv.pairs[kv.focusIndex].Value) > 0 { - kv.pairs[kv.focusIndex].Value = kv.pairs[kv.focusIndex].Value[:len(kv.pairs[kv.focusIndex].Value)-1] - } - default: - // Add printable characters - if len(msg.String()) == 1 && msg.String() >= " " { - char := msg.String() - if kv.fieldIndex == 0 { - kv.pairs[kv.focusIndex].Key += char - } else if kv.fieldIndex == 1 { - kv.pairs[kv.focusIndex].Value += char - } - } - } - } - - return kv, nil -} - -func (kv KeyValueEditor) View() string { - // Calculate container dimensions (use full width like textarea) - containerWidth := kv.width - 4 // Just account for padding - if containerWidth < 30 { - containerWidth = 30 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(kv.height). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(1, 1) - - if kv.focused { - container = container.BorderForeground(styles.Primary) - } - - // Build content - var lines []string - visibleHeight := kv.height - 2 // Account for border - - // Header - better column proportions - headerStyle := styles.ListItemStyle.Copy().Bold(true) - availableWidth := containerWidth - 8 // Account for padding and separators - keyWidth := availableWidth * 40 / 100 // 40% for key - valueWidth := availableWidth * 50 / 100 // 50% for value - enabledWidth := availableWidth * 10 / 100 // 10% for enabled - - header := lipgloss.JoinHorizontal( - lipgloss.Top, - headerStyle.Copy().Width(keyWidth).Render("Key"), - " ", - headerStyle.Copy().Width(valueWidth).Render("Value"), - " ", - headerStyle.Copy().Width(enabledWidth).Align(lipgloss.Center).Render("On"), - ) - lines = append(lines, header) - - // Show pairs (limit to visible height) - maxPairs := visibleHeight - 2 // Reserve space for header and instructions - if maxPairs < 1 { - maxPairs = 1 - } - - for i := 0; i < maxPairs && i < len(kv.pairs); i++ { - pair := kv.pairs[i] - - // Style fields based on focus - keyStyle := styles.ListItemStyle.Copy().Width(keyWidth) - valueStyle := styles.ListItemStyle.Copy().Width(valueWidth) - enabledStyle := styles.ListItemStyle.Copy().Width(enabledWidth).Align(lipgloss.Center) - - if kv.focused && i == kv.focusIndex { - if kv.fieldIndex == 0 { - keyStyle = keyStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } else if kv.fieldIndex == 1 { - valueStyle = valueStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } else if kv.fieldIndex == 2 { - enabledStyle = enabledStyle.Background(styles.Primary).Foreground(styles.TextPrimary) - } - } - - // Truncate long text - keyText := pair.Key - if len(keyText) > keyWidth-2 { - keyText = keyText[:keyWidth-2] - } - valueText := pair.Value - if len(valueText) > valueWidth-2 { - valueText = valueText[:valueWidth-2] - } - - checkbox := "☐" - if pair.Enabled { - checkbox = "☑" - } - - row := lipgloss.JoinHorizontal( - lipgloss.Top, - keyStyle.Render(keyText), - " ", - valueStyle.Render(valueText), - " ", - enabledStyle.Render(checkbox), - ) - lines = append(lines, row) - } - - // Add instructions at bottom - if len(lines) < visibleHeight-1 { - instructions := "tab: next field • ↑↓: navigate rows • space: toggle" - instrStyle := styles.ListItemStyle.Copy().Foreground(styles.TextMuted) - lines = append(lines, "", instrStyle.Render(instructions)) - } - - // Fill remaining space - for len(lines) < visibleHeight { - lines = append(lines, "") - } - - content := lipgloss.JoinVertical(lipgloss.Left, lines...) - containerView := container.Render(content) - - return containerView -} diff --git a/internal/tui/components/layout.go b/internal/tui/components/layout.go deleted file mode 100644 index a191e07..0000000 --- a/internal/tui/components/layout.go +++ /dev/null @@ -1,146 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Layout struct { - width int - height int -} - -func NewLayout() Layout { - return Layout{} -} - -func (l *Layout) SetSize(width, height int) { - l.width = width - l.height = height -} - -func (l Layout) Header(title string) string { - return styles.HeaderStyle. - Width(l.width). - Render(title) -} - -func (l Layout) Footer(instructions string) string { - return styles.FooterStyle. - Width(l.width). - Render(instructions) -} - -func (l Layout) Content(content string, headerHeight, footerHeight int) string { - contentHeight := l.height - headerHeight - footerHeight - if contentHeight < 0 { - contentHeight = 0 - } - - return styles.ContentStyle. - Width(l.width). - Height(contentHeight). - Render(content) -} - -func (l Layout) FullView(title, content, instructions string) string { - if l.width < 20 || l.height < 10 { - return content - } - - // Calculate window dimensions (85% of terminal width, 80% height) - windowWidth := int(float64(l.width) * 0.85) - windowHeight := int(float64(l.height) * 0.8) - - // Ensure minimum dimensions - if windowWidth < 50 { - windowWidth = 50 - } - if windowHeight < 15 { - windowHeight = 15 - } - - // Calculate inner content dimensions (accounting for border) - innerWidth := windowWidth - 4 // 2 chars for border + padding - innerHeight := windowHeight - 4 - - // Create header and content with simplified, consistent styling - header := lipgloss.NewStyle(). - Width(innerWidth). - Padding(1, 2). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true). - Align(lipgloss.Center). - Render(title) - - headerHeight := lipgloss.Height(header) - contentHeight := innerHeight - headerHeight - - if contentHeight < 1 { - contentHeight = 1 - } - - contentArea := lipgloss.NewStyle(). - Width(innerWidth). - Height(contentHeight). - Padding(1, 2). - Render(content) - - // Join header and content vertically (no footer) - windowContent := lipgloss.JoinVertical( - lipgloss.Left, - header, - contentArea, - ) - - // Create bordered window - borderedWindow := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("15")). // White border - Width(windowWidth). - Height(windowHeight). - Render(windowContent) - - // Create elegant app branding banner at top - brandingText := "Req - Test APIs with Terminal Velocity" - appBranding := lipgloss.NewStyle(). - Width(l.width). - Align(lipgloss.Center). - Foreground(lipgloss.Color("230")). // Soft cream - // Background(lipgloss.Color("237")). // Dark gray background - Bold(true). - Padding(1, 4). - Margin(1, 0). - Render(brandingText) - - // Create footer outside the window - footer := lipgloss.NewStyle(). - Width(l.width). - Padding(0, 2). - Foreground(styles.TextSecondary). - Align(lipgloss.Center). - Render(instructions) - - // Calculate vertical position accounting for branding and footer - brandingHeight := lipgloss.Height(appBranding) - footerHeight := lipgloss.Height(footer) - windowPlacementHeight := l.height - brandingHeight - footerHeight - 4 // Extra padding - - centeredWindow := lipgloss.Place( - l.width, windowPlacementHeight, - lipgloss.Center, lipgloss.Center, - borderedWindow, - ) - - // Combine branding, centered window, and footer with proper spacing - return lipgloss.JoinVertical( - lipgloss.Left, - "", // Top padding - appBranding, - "", // Extra spacing line - centeredWindow, - "", // Reduced spacing before footer - footer, - ) -} diff --git a/internal/tui/components/paginated_list.go b/internal/tui/components/paginated_list.go deleted file mode 100644 index 10710f6..0000000 --- a/internal/tui/components/paginated_list.go +++ /dev/null @@ -1,119 +0,0 @@ -package components - -import ( - "fmt" - "io" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type ListItem interface { - list.Item - GetID() string - GetTitle() string - GetDescription() string -} - -type PaginatedList struct { - list list.Model - width int - height int -} - -func NewPaginatedList(items []ListItem, title string) PaginatedList { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = item - } - - const defaultWidth = 120 // Wide enough to avoid title truncation - const defaultHeight = 20 - - l := list.New(listItems, paginatedItemDelegate{}, defaultWidth, defaultHeight) - l.Title = title - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.SetShowHelp(false) - l.SetShowPagination(false) - l.SetShowTitle(true) - - l.Styles.StatusBar = lipgloss.NewStyle() - l.Styles.PaginationStyle = lipgloss.NewStyle() - l.Styles.HelpStyle = lipgloss.NewStyle() - l.Styles.FilterPrompt = lipgloss.NewStyle() - l.Styles.FilterCursor = lipgloss.NewStyle() - l.Styles.Title = styles.TitleStyle.Copy().MarginBottom(0).PaddingBottom(0) - - return PaginatedList{ - list: l, - } -} - -func (pl *PaginatedList) SetSize(width, height int) { - pl.width = width - pl.height = height - - // Safety check to prevent nil pointer dereference - if width > 0 && height > 0 { - pl.list.SetWidth(width) - pl.list.SetHeight(height) - } -} - -func (pl PaginatedList) Init() tea.Cmd { - return nil -} - -func (pl PaginatedList) Update(msg tea.Msg) (PaginatedList, tea.Cmd) { - newListModel, cmd := pl.list.Update(msg) - pl.list = newListModel - return pl, cmd -} - -func (pl PaginatedList) View() string { - return pl.list.View() -} - -func (pl PaginatedList) SelectedItem() ListItem { - if selectedItem := pl.list.SelectedItem(); selectedItem != nil { - if listItem, ok := selectedItem.(ListItem); ok { - return listItem - } - } - return nil -} - -func (pl PaginatedList) SelectedIndex() int { - return pl.list.Index() -} - -func (pl *PaginatedList) SetIndex(i int) { - pl.list.Select(i) -} - -func (pl PaginatedList) IsFiltering() bool { - return pl.list.FilterState() == list.Filtering -} - -type paginatedItemDelegate struct{} - -func (d paginatedItemDelegate) Height() int { return 1 } -func (d paginatedItemDelegate) Spacing() int { return 0 } -func (d paginatedItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d paginatedItemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - if i, ok := item.(ListItem); ok { - str := i.GetTitle() - - fn := styles.ListItemStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - return styles.SelectedListItemStyle.Render("> " + s[0]) - } - } - - fmt.Fprint(w, fn(str)) - } -} diff --git a/internal/tui/components/text_input.go b/internal/tui/components/text_input.go deleted file mode 100644 index 4049b1f..0000000 --- a/internal/tui/components/text_input.go +++ /dev/null @@ -1,107 +0,0 @@ -package components - -import ( - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type TextInput struct { - textInput textinput.Model - label string - width int -} - -func NewTextInput(label, placeholder string) TextInput { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Focus() - ti.CharLimit = 5000 // Allow long content like JSON - ti.Width = 50 - - return TextInput{ - textInput: ti, - label: label, - width: 50, - } -} - -func (t *TextInput) SetValue(value string) { - t.textInput.SetValue(value) -} - -func (t TextInput) Value() string { - return t.textInput.Value() -} - -func (t *TextInput) SetWidth(width int) { - t.width = width - // Account for label, colon, spacing, and border padding - containerWidth := width - 12 - 1 - 2 // 12 for label, 1 for colon, 2 for spacing - if containerWidth < 15 { - containerWidth = 15 - } - - // The actual input width inside the container (subtract border and padding) - inputWidth := containerWidth - 4 // 2 for border, 2 for padding - if inputWidth < 10 { - inputWidth = 10 - } - - // Ensure the underlying textinput respects the width - t.textInput.Width = inputWidth -} - -func (t *TextInput) Focus() { - t.textInput.Focus() -} - -func (t *TextInput) Blur() { - t.textInput.Blur() -} - -func (t *TextInput) Clear() { - t.textInput.SetValue("") -} - -func (t TextInput) Focused() bool { - return t.textInput.Focused() -} - -func (t TextInput) Update(msg tea.Msg) (TextInput, tea.Cmd) { - var cmd tea.Cmd - t.textInput, cmd = t.textInput.Update(msg) - return t, cmd -} - -func (t TextInput) View() string { - labelStyle := styles.TitleStyle.Copy(). - Width(12). - MarginTop(1). - Align(lipgloss.Right) - - // Create a fixed-width container for the input to prevent overflow - containerWidth := t.width - 12 - 1 - 2 // Account for label, colon, spacing - if containerWidth < 15 { - containerWidth = 15 - } - - inputContainer := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(1). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 1) - - if t.textInput.Focused() { - inputContainer = inputContainer.BorderForeground(styles.Primary) - } - - return lipgloss.JoinHorizontal( - lipgloss.Top, - labelStyle.Render(t.label+":"), - " ", - inputContainer.Render(t.textInput.View()), - ) -} diff --git a/internal/tui/components/textarea.go b/internal/tui/components/textarea.go deleted file mode 100644 index 01ba878..0000000 --- a/internal/tui/components/textarea.go +++ /dev/null @@ -1,311 +0,0 @@ -package components - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/tui/styles" -) - -type Textarea struct { - label string - content string - width int - height int - focused bool - cursor int - lines []string - cursorRow int - cursorCol int - scrollOffset int -} - -func NewTextarea(label, placeholder string) Textarea { - return Textarea{ - label: label, - content: "", - width: 50, - height: 6, - focused: false, - cursor: 0, - lines: []string{""}, - cursorRow: 0, - cursorCol: 0, - scrollOffset: 0, - } -} - -func (t *Textarea) SetValue(value string) { - t.content = value - rawLines := strings.Split(value, "\n") - if len(rawLines) == 0 { - rawLines = []string{""} - } - - // Wrap long lines to fit within the textarea width - t.lines = []string{} - contentWidth := t.getContentWidth() - - for _, line := range rawLines { - if len(line) <= contentWidth { - t.lines = append(t.lines, line) - } else { - // Wrap long lines - wrapped := t.wrapLine(line, contentWidth) - t.lines = append(t.lines, wrapped...) - } - } - - if len(t.lines) == 0 { - t.lines = []string{""} - } - - // Set cursor to end - t.cursorRow = len(t.lines) - 1 - t.cursorCol = len(t.lines[t.cursorRow]) -} - -func (t Textarea) Value() string { - return strings.Join(t.lines, "\n") -} - -func (t *Textarea) SetSize(width, height int) { - t.width = width - t.height = height -} - -func (t *Textarea) Focus() { - t.focused = true -} - -func (t *Textarea) Blur() { - t.focused = false -} - -func (t Textarea) Focused() bool { - return t.focused -} - -func (t *Textarea) moveCursor(row, col int) { - // Ensure row is in bounds - if row < 0 { - row = 0 - } - if row >= len(t.lines) { - row = len(t.lines) - 1 - } - - // Ensure col is in bounds for the row - if col < 0 { - col = 0 - } - if col > len(t.lines[row]) { - col = len(t.lines[row]) - } - - t.cursorRow = row - t.cursorCol = col -} - -func (t Textarea) Update(msg tea.Msg) (Textarea, tea.Cmd) { - if !t.focused { - return t, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "enter": - // Insert new line - currentLine := t.lines[t.cursorRow] - beforeCursor := currentLine[:t.cursorCol] - afterCursor := currentLine[t.cursorCol:] - - t.lines[t.cursorRow] = beforeCursor - newLines := make([]string, len(t.lines)+1) - copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1]) - newLines[t.cursorRow+1] = afterCursor - copy(newLines[t.cursorRow+2:], t.lines[t.cursorRow+1:]) - t.lines = newLines - - t.cursorRow++ - t.cursorCol = 0 - - case "tab": - // Insert 2 spaces for indentation - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + " " + currentLine[t.cursorCol:] - t.cursorCol += 2 - - case "backspace": - if t.cursorCol > 0 { - // Remove character - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol-1] + currentLine[t.cursorCol:] - t.cursorCol-- - } else if t.cursorRow > 0 { - // Join with previous line - prevLine := t.lines[t.cursorRow-1] - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow-1] = prevLine + currentLine - - newLines := make([]string, len(t.lines)-1) - copy(newLines[:t.cursorRow], t.lines[:t.cursorRow]) - copy(newLines[t.cursorRow:], t.lines[t.cursorRow+1:]) - t.lines = newLines - - t.cursorRow-- - t.cursorCol = len(prevLine) - } - - case "delete": - if t.cursorCol < len(t.lines[t.cursorRow]) { - // Remove character - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + currentLine[t.cursorCol+1:] - } else if t.cursorRow < len(t.lines)-1 { - // Join with next line - currentLine := t.lines[t.cursorRow] - nextLine := t.lines[t.cursorRow+1] - t.lines[t.cursorRow] = currentLine + nextLine - - newLines := make([]string, len(t.lines)-1) - copy(newLines[:t.cursorRow+1], t.lines[:t.cursorRow+1]) - copy(newLines[t.cursorRow+1:], t.lines[t.cursorRow+2:]) - t.lines = newLines - } - - case "up": - t.moveCursor(t.cursorRow-1, t.cursorCol) - case "down": - t.moveCursor(t.cursorRow+1, t.cursorCol) - case "left": - if t.cursorCol > 0 { - t.cursorCol-- - } else if t.cursorRow > 0 { - t.cursorRow-- - t.cursorCol = len(t.lines[t.cursorRow]) - } - case "right": - if t.cursorCol < len(t.lines[t.cursorRow]) { - t.cursorCol++ - } else if t.cursorRow < len(t.lines)-1 { - t.cursorRow++ - t.cursorCol = 0 - } - case "home": - t.cursorCol = 0 - case "end": - t.cursorCol = len(t.lines[t.cursorRow]) - - default: - // Insert printable characters - if len(msg.String()) == 1 && msg.String() >= " " { - char := msg.String() - currentLine := t.lines[t.cursorRow] - t.lines[t.cursorRow] = currentLine[:t.cursorCol] + char + currentLine[t.cursorCol:] - t.cursorCol++ - } - } - } - - return t, nil -} - -func (t Textarea) View() string { - // Use full width since we don't need label space - containerWidth := t.width - 4 // Just account for padding - if containerWidth < 20 { - containerWidth = 20 - } - - // Create the textarea container - containerHeight := t.height - if containerHeight < 3 { - containerHeight = 3 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(containerHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 1) - - if t.focused { - container = container.BorderForeground(styles.Primary) - } - - // Prepare visible lines with cursor - visibleLines := make([]string, containerHeight-2) // Account for border - for i := 0; i < len(visibleLines); i++ { - lineIndex := i // No scrolling for now - if lineIndex < len(t.lines) { - line := t.lines[lineIndex] - - // Add cursor if this is the cursor row and textarea is focused - if t.focused && lineIndex == t.cursorRow { - if t.cursorCol <= len(line) { - line = line[:t.cursorCol] + "│" + line[t.cursorCol:] - } - } - - // Lines should already be wrapped, no need to truncate - - visibleLines[i] = line - } else { - visibleLines[i] = "" - } - } - - content := strings.Join(visibleLines, "\n") - textareaView := container.Render(content) - - return textareaView -} - -func (t Textarea) getContentWidth() int { - // Calculate content width (no label needed) - containerWidth := t.width - 4 // Just account for padding - if containerWidth < 20 { - containerWidth = 20 - } - contentWidth := containerWidth - 4 // border + padding - if contentWidth < 10 { - contentWidth = 10 - } - return contentWidth -} - -func (t Textarea) wrapLine(line string, maxWidth int) []string { - if len(line) <= maxWidth { - return []string{line} - } - - var wrapped []string - for len(line) > maxWidth { - // Find the best place to break (prefer spaces) - breakPoint := maxWidth - for i := maxWidth - 1; i >= maxWidth-20 && i >= 0; i-- { - if line[i] == ' ' { - breakPoint = i - break - } - } - - wrapped = append(wrapped, line[:breakPoint]) - line = line[breakPoint:] - - // Skip leading space on continuation lines - if len(line) > 0 && line[0] == ' ' { - line = line[1:] - } - } - - if len(line) > 0 { - wrapped = append(wrapped, line) - } - - return wrapped -} diff --git a/internal/tui/keybinds/collections-binds.go b/internal/tui/keybinds/collections-binds.go new file mode 100644 index 0000000..ed8c972 --- /dev/null +++ b/internal/tui/keybinds/collections-binds.go @@ -0,0 +1,49 @@ +package keybinds + +import "github.com/charmbracelet/bubbles/key" + +type ListKeyMap struct { + CursorUp key.Binding + CursorDown key.Binding + NextPage key.Binding + PrevPage key.Binding + Filter key.Binding + ClearFilter key.Binding + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + AddItem key.Binding + EditItem key.Binding + DeleteItem key.Binding + Choose key.Binding + Accept key.Binding + Back key.Binding +} + +func (c ListKeyMap) ShortHelp() []key.Binding { + return []key.Binding{c.CursorUp, c.CursorDown, c.NextPage, c.PrevPage, c.AddItem, c.EditItem, c.DeleteItem} +} + +func (c ListKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.CursorUp, c.CursorDown, c.NextPage, c.PrevPage}, + } +} + +func NewListKeyMap() *ListKeyMap { + return &ListKeyMap{ + CursorUp: Keys.Up, + CursorDown: Keys.Down, + NextPage: Keys.NextPage, + PrevPage: Keys.PrevPage, + Filter: Keys.Filter, + ClearFilter: Keys.ClearFilter, + CancelWhileFiltering: Keys.CancelWhileFiltering, + AcceptWhileFiltering: Keys.AcceptWhileFiltering, + AddItem: Keys.InsertItem, + DeleteItem: Keys.Remove, + EditItem: Keys.EditItem, + Choose: Keys.Choose, + Accept: Keys.Choose, + Back: Keys.Back, + } +} diff --git a/internal/tui/keybinds/keys.go b/internal/tui/keybinds/keys.go new file mode 100644 index 0000000..d04f267 --- /dev/null +++ b/internal/tui/keybinds/keys.go @@ -0,0 +1,82 @@ +package keybinds + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type Keymaps struct { + InsertItem key.Binding + DeleteItem key.Binding + EditItem key.Binding + Choose key.Binding + Remove key.Binding + Back key.Binding + Up key.Binding + Down key.Binding + NextPage key.Binding + PrevPage key.Binding + Filter key.Binding + ClearFilter key.Binding + CancelWhileFiltering key.Binding + AcceptWhileFiltering key.Binding + Quit key.Binding +} + +var Keys = Keymaps{ + Back: key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "back"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "back"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PrevPage: key.NewBinding( + key.WithKeys("left", "h", "pgup"), + key.WithHelp("←/h/pgup", "prev page"), + ), + NextPage: key.NewBinding( + key.WithKeys("right", "l", "pgdown"), + key.WithHelp("→/l/pgdn", "next page"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "clear filter"), + ), + CancelWhileFiltering: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + AcceptWhileFiltering: key.NewBinding( + key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), + key.WithHelp("enter", "apply filter"), + ), + InsertItem: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add item"), + ), + EditItem: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit item"), + ), + Choose: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Choose"), + ), + Remove: key.NewBinding( + key.WithKeys("x", "backspace"), + key.WithHelp("x", "delete"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), +} diff --git a/internal/tui/keybinds/types.go b/internal/tui/keybinds/types.go new file mode 100644 index 0000000..b360631 --- /dev/null +++ b/internal/tui/keybinds/types.go @@ -0,0 +1,22 @@ +package keybinds + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type Help struct { + Keys []key.Binding +} + +func (h Help) ShortHelp() []key.Binding { + return h.Keys +} + +func (h Help) FullHelp() [][]key.Binding { + // TODO: Figure how you wanna show this + return [][]key.Binding{} +} + +func (h Help) SetHelp(helpMenu []key.Binding) { + h.Keys = helpMenu +} diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go new file mode 100644 index 0000000..a3192b6 --- /dev/null +++ b/internal/tui/messages/messages.go @@ -0,0 +1,31 @@ +package messages + +type ItemAdded struct { + Item string +} +type ItemEdited struct { + Item string + ItemID int64 +} + +type DeleteItem struct { + ItemID int64 +} + +type ChooseItem[T any] struct { + Item T + Source string +} + +type DeactivateView struct{} + +type NavigateToView struct { + ViewName string + Data interface{} +} + +type RefreshItemsList struct{} + +type ShowError struct { + Message string +} diff --git a/internal/tui/styles/app-styles.go b/internal/tui/styles/app-styles.go new file mode 100644 index 0000000..365ef5d --- /dev/null +++ b/internal/tui/styles/app-styles.go @@ -0,0 +1,17 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" +) + +var ( + footerNameStyle = lipgloss.NewStyle().Bold(true).Background(footerNameBG) + footerNameBGStyle = lipgloss.NewStyle().Background(footerNameBG).Padding(0, 3, 0) + FooterSegmentStyle = lipgloss.NewStyle().Background(footerSegmentBG).PaddingLeft(2).Foreground(footerSegmentFG) + FooterVersionStyle = lipgloss.NewStyle().Background(footerSegmentBG).AlignHorizontal(lipgloss.Right).PaddingRight(2).Foreground(footerSegmentFG) + TabHeadingInactive = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + TabHeadingActive = lipgloss.NewStyle().Background(accent).Foreground(headingForeground).Width(25).AlignHorizontal(lipgloss.Center).Border(lipgloss.NormalBorder(), false, false, false, true) + HelpStyle = lipgloss.NewStyle().Padding(1, 0, 1, 2) + AppHelpStyle = lipgloss.NewStyle().Padding(1, 0).Foreground(helpFG) + ErrorBarStyle = lipgloss.NewStyle().Background(lipgloss.Color("#FF0000")).Foreground(lipgloss.Color("#FFFFFF")).Padding(0, 1) +) diff --git a/internal/tui/styles/collections-styles.go b/internal/tui/styles/collections-styles.go new file mode 100644 index 0000000..600e885 --- /dev/null +++ b/internal/tui/styles/collections-styles.go @@ -0,0 +1,8 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + SelectedListStyle = lipgloss.NewStyle().Foreground(accent).PaddingLeft(1).Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(accent) + InputStyle = lipgloss.NewStyle().Padding(1, 2).Border(lipgloss.NormalBorder(), false, false, false, true).Margin(1, 0) +) diff --git a/internal/tui/styles/colors.go b/internal/tui/styles/colors.go index 958f23a..9cb8232 100644 --- a/internal/tui/styles/colors.go +++ b/internal/tui/styles/colors.go @@ -3,19 +3,12 @@ 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 + footerNameBG = lipgloss.Color("#1a1a1a") + footerNameFGFrom = lipgloss.Color("#41A0AE") + footerNameFGTo = lipgloss.Color("#77F07F") + accent = lipgloss.Color("#77F07F") + headingForeground = lipgloss.Color("#000000") + footerSegmentBG = lipgloss.Color("#262626") + footerSegmentFG = lipgloss.Color("#656565") + helpFG = lipgloss.Color("#3C3C3C") ) diff --git a/internal/tui/styles/functions.go b/internal/tui/styles/functions.go new file mode 100644 index 0000000..422250b --- /dev/null +++ b/internal/tui/styles/functions.go @@ -0,0 +1,43 @@ +package styles + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func gradientText(text string, startColor, endColor lipgloss.Color, base, additional lipgloss.Style) string { + n := len(text) + result := "" + + for i := range n { + ratio := float64(i) / float64(n-1) + color := interpolateColor(startColor, endColor, ratio) + + style := base.Foreground(lipgloss.Color(color)) + result += style.Render(string(text[i])) + } + + return additional.Render(result) +} + +func interpolateColor(start, end lipgloss.Color, ratio float64) string { + r1, g1, b1 := hexToRGB(string(start)) + r2, g2, b2 := hexToRGB(string(end)) + + r := int(float64(r1) + (float64(r2)-float64(r1))*ratio) + g := int(float64(g1) + (float64(g2)-float64(g1))*ratio) + b := int(float64(b1) + (float64(b2)-float64(b1))*ratio) + + return fmt.Sprintf("#%02X%02X%02X", r, g, b) +} + +func hexToRGB(hex string) (int, int, int) { + var r, g, b int + fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b) + return r, g, b +} + +func ApplyGradientToFooter(text string) string { + return gradientText("REQ", footerNameFGFrom, footerNameFGTo, footerNameStyle, footerNameBGStyle) +} diff --git a/internal/tui/styles/layout.go b/internal/tui/styles/layout.go deleted file mode 100644 index 8f933ba..0000000 --- a/internal/tui/styles/layout.go +++ /dev/null @@ -1,41 +0,0 @@ -package styles - -import "github.com/charmbracelet/lipgloss" - -var ( - HeaderStyle = lipgloss.NewStyle(). - Padding(1, 2). - Background(Primary). - Foreground(TextPrimary). - Bold(true). - Align(lipgloss.Center) - - FooterStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(TextSecondary). - Align(lipgloss.Center) - - ContentStyle = lipgloss.NewStyle(). - Padding(1, 2) - - ListItemStyle = lipgloss.NewStyle(). - PaddingLeft(4) - - SelectedListItemStyle = lipgloss.NewStyle(). - PaddingLeft(2). - Foreground(Secondary) - - TitleStyle = lipgloss.NewStyle(). - MarginLeft(2). - MarginBottom(1). - Foreground(Primary). - Bold(true) - - SidebarStyle = lipgloss.NewStyle(). - BorderRight(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(Secondary) - - MainContentStyle = lipgloss.NewStyle(). - PaddingLeft(2) -) diff --git a/internal/tui/views/add_collection.go b/internal/tui/views/add_collection.go deleted file mode 100644 index 8d33600..0000000 --- a/internal/tui/views/add_collection.go +++ /dev/null @@ -1,140 +0,0 @@ -package views - -import ( - "context" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" -) - -type AddCollectionView struct { - layout components.Layout - form components.Form - collectionsManager *collections.CollectionsManager - width int - height int - submitting bool -} - -func NewAddCollectionView(collectionsManager *collections.CollectionsManager) AddCollectionView { - inputs := []components.TextInput{ - components.NewTextInput("Name", "Enter collection name"), - } - - form := components.NewForm("Add Collection", inputs) - form.SetSubmitText("Create") - - return AddCollectionView{ - layout: components.NewLayout(), - form: form, - collectionsManager: collectionsManager, - } -} - -func (v AddCollectionView) Init() tea.Cmd { - return nil -} - -func (v AddCollectionView) Update(msg tea.Msg) (AddCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-50, v.height-8) - - case tea.KeyMsg: - if v.submitting { - return v, nil - } - - switch msg.String() { - case "enter": - return v, func() tea.Msg { return v.submitForm() } - case "esc": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - } - - case CollectionCreateErrorMsg: - v.submitting = false - } - - v.form, cmd = v.form.Update(msg) - return v, cmd -} - -func (v *AddCollectionView) submitForm() tea.Msg { - v.submitting = true - values := v.form.GetValues() - - if len(values) == 0 || values[0] == "" { - return CollectionCreateErrorMsg{err: crud.ErrInvalidInput} - } - - return v.createCollection(values[0]) -} - -func (v *AddCollectionView) createCollection(name string) tea.Msg { - collection, err := v.collectionsManager.Create(context.Background(), name) - if err != nil { - return CollectionCreateErrorMsg{err: err} - } - return CollectionCreatedMsg{collection: collection} -} - -func (v *AddCollectionView) ClearForm() { - v.form.Clear() -} - -func (v AddCollectionView) View() string { - if v.submitting { - return v.layout.FullView( - "Add Collection", - "Creating collection...", - "Please wait", - ) - } - - content := v.form.View() - instructions := "tab/↑↓: navigate • enter: create • esc: cancel" - - return v.layout.FullView( - "Add Collection", - content, - instructions, - ) -} - -type CollectionCreatedMsg struct { - collection collections.CollectionEntity -} - -type CollectionCreateErrorMsg struct { - err error -} - -type CollectionUpdatedMsg struct { - collection collections.CollectionEntity -} - -type CollectionUpdateErrorMsg struct { - err error -} - -type CollectionDeletedMsg struct { - ID int64 -} - -type CollectionDeleteErrorMsg struct { - Err error -} - -type BackToCollectionsMsg struct{} - -type EditCollectionMsg struct { - Collection collections.CollectionEntity -} diff --git a/internal/tui/views/collections-view-helper.go b/internal/tui/views/collections-view-helper.go new file mode 100644 index 0000000..410b0e0 --- /dev/null +++ b/internal/tui/views/collections-view-helper.go @@ -0,0 +1,39 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/list" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/styles" +) + +func createDelegate() list.DefaultDelegate { + d := list.NewDefaultDelegate() + + d.Styles.SelectedTitle = styles.SelectedListStyle + d.Styles.SelectedDesc = styles.SelectedListStyle + + return d +} + +func defaultListConfig[T, U any](binds *keybinds.ListKeyMap) *optionsProvider.ListConfig[T, U] { + config := optionsProvider.ListConfig[T, U]{ + ShowPagination: true, + ShowStatusBar: false, + ShowHelp: false, + ShowTitle: false, + FilteringEnabled: true, + Delegate: createDelegate(), + KeyMap: list.KeyMap{ + CursorUp: binds.CursorUp, + CursorDown: binds.CursorDown, + NextPage: binds.NextPage, + PrevPage: binds.PrevPage, + Filter: binds.Filter, + ClearFilter: binds.ClearFilter, + CancelWhileFiltering: binds.CancelWhileFiltering, + AcceptWhileFiltering: binds.AcceptWhileFiltering, + }, + } + return &config +} diff --git a/internal/tui/views/collections-view.go b/internal/tui/views/collections-view.go new file mode 100644 index 0000000..e6f08a9 --- /dev/null +++ b/internal/tui/views/collections-view.go @@ -0,0 +1,152 @@ +package views + +import ( + "context" + "errors" + "fmt" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/backend/collections" + "github.com/maniac-en/req/internal/backend/endpoints" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" +) + +type CollectionsView struct { + width int + height int + list optionsProvider.OptionsProvider[collections.CollectionEntity, string] + manager *collections.CollectionsManager + endpointsManager *endpoints.EndpointsManager + help help.Model + keys *keybinds.ListKeyMap + order int +} + +func (c CollectionsView) Init() tea.Cmd { + return nil +} + +func (c CollectionsView) Name() string { + return "Collections" +} + +func (c CollectionsView) Help() []key.Binding { + return c.list.Help() +} + +func (c CollectionsView) GetFooterSegment() string { + return c.list.GetSelected().Title() +} + +func (c CollectionsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + c.height = msg.Height + c.width = msg.Width + c.list, cmd = c.list.Update(msg) + cmds = append(cmds, cmd) + case messages.ItemAdded: + _, err := c.manager.Create(context.Background(), msg.Item) + if err != nil { + return c, func() tea.Msg { + return messages.ShowError{Message: err.Error()} + } + } + return c, func() tea.Msg { + return messages.RefreshItemsList{} + } + case messages.RefreshItemsList: + c.list.RefreshItems() + return c, nil + case messages.ItemEdited: + c.manager.Update(context.Background(), msg.ItemID, msg.Item) + case messages.DeleteItem: + c.manager.Delete(context.Background(), msg.ItemID) + c.list.RefreshItems() + } + + c.list, cmd = c.list.Update(msg) + cmds = append(cmds, cmd) + + return c, tea.Batch(cmds...) +} + +func (c CollectionsView) View() string { + return c.list.View() +} + +func (c CollectionsView) SetState(items ...any) error { + return errors.New("This view does not implement set state") +} + +func (c CollectionsView) Order() int { + return c.order +} + +func (c CollectionsView) OnFocus() { + +} + +func (c CollectionsView) OnBlur() { + +} + +func itemMapper(items []collections.CollectionEntity, endpointsManager *endpoints.EndpointsManager) []list.Item { + opts := make([]list.Item, len(items)) + + counts, err := endpointsManager.GetCountsByCollections(context.Background()) + if err != nil { + for i, item := range items { + opts[i] = optionsProvider.Option{ + Name: item.GetName(), + Subtext: "0 endpoints", + ID: item.GetID(), + } + } + return opts + } + + countMap := make(map[int64]int) + for _, count := range counts { + countMap[count.CollectionID] = int(count.Count) + } + + for i, item := range items { + count := countMap[item.GetID()] + opts[i] = optionsProvider.Option{ + Name: item.GetName(), + Subtext: fmt.Sprintf("%d endpoints", count), + ID: item.GetID(), + } + } + + return opts +} + +func NewCollectionsView(collManager *collections.CollectionsManager, endpointsManager *endpoints.EndpointsManager, order int) *CollectionsView { + keybinds := keybinds.NewListKeyMap() + config := defaultListConfig[collections.CollectionEntity, string](keybinds) + + config.GetItemsFunc = collManager.List + config.ItemMapper = func(items []collections.CollectionEntity) []list.Item { + return itemMapper(items, endpointsManager) + } + config.AdditionalKeymaps = keybinds + config.Source = "collections" + + return &CollectionsView{ + list: optionsProvider.NewOptionsProvider(config), + manager: collManager, + endpointsManager: endpointsManager, + help: help.New(), + keys: keybinds, + order: order, + } +} diff --git a/internal/tui/views/collections.go b/internal/tui/views/collections.go deleted file mode 100644 index c5a8576..0000000 --- a/internal/tui/views/collections.go +++ /dev/null @@ -1,235 +0,0 @@ -package views - -import ( - "context" - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type CollectionsView struct { - layout components.Layout - list components.PaginatedList - collectionsManager *collections.CollectionsManager - width int - height int - initialized bool - selectedIndex int - showDummyDataNotif bool - - currentPage int - pageSize int - pagination crud.PaginationMetadata -} - -func NewCollectionsView(collectionsManager *collections.CollectionsManager) CollectionsView { - return CollectionsView{ - layout: components.NewLayout(), - collectionsManager: collectionsManager, - } -} - -func NewCollectionsViewWithSize(collectionsManager *collections.CollectionsManager, width, height int) CollectionsView { - layout := components.NewLayout() - layout.SetSize(width, height) - return CollectionsView{ - layout: layout, - collectionsManager: collectionsManager, - width: width, - height: height, - } -} - -func (v *CollectionsView) SetDummyDataNotification(show bool) { - v.showDummyDataNotif = show -} - -func (v CollectionsView) Init() tea.Cmd { - return v.loadCollections -} - -func (v *CollectionsView) loadCollections() tea.Msg { - pageToLoad := v.currentPage - if pageToLoad == 0 { - pageToLoad = 1 - } - pageSizeToLoad := v.pageSize - if pageSizeToLoad == 0 { - pageSizeToLoad = 20 - } - - if v.initialized { - v.selectedIndex = v.list.SelectedIndex() - } else { - v.selectedIndex = 0 - } - - return v.loadCollectionsPage(pageToLoad, pageSizeToLoad) -} - -func (v *CollectionsView) loadCollectionsPage(page, pageSize int) tea.Msg { - offset := (page - 1) * pageSize - result, err := v.collectionsManager.ListPaginated(context.Background(), pageSize, offset) - if err != nil { - return collectionsLoadError{err: err} - } - return collectionsLoaded{ - collections: result.Collections, - pagination: result.PaginationMetadata, - currentPage: page, - pageSize: pageSize, - } -} - -type collectionsLoaded struct { - collections []collections.CollectionEntity - pagination crud.PaginationMetadata - currentPage int - pageSize int -} - -type collectionsLoadError struct { - err error -} - -func (v CollectionsView) Update(msg tea.Msg) (CollectionsView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - - case collectionsLoaded: - items := make([]components.ListItem, len(msg.collections)) - for i, collection := range msg.collections { - items[i] = components.NewCollectionItem(collection) - } - - v.currentPage = msg.currentPage - v.pageSize = msg.pageSize - v.pagination = msg.pagination - - title := fmt.Sprintf("Page %d / %d", v.currentPage, v.pagination.TotalPages) - v.list = components.NewPaginatedList(items, title) - v.list.SetIndex(v.selectedIndex) - - v.initialized = true - - case collectionsLoadError: - v.initialized = true - - case tea.KeyMsg: - if !v.initialized { - break - } - - // Clear dummy data notification on any keypress - if v.showDummyDataNotif { - v.showDummyDataNotif = false - } - - if !v.list.IsFiltering() { - switch msg.String() { - case "n", "right": - if v.currentPage < v.pagination.TotalPages { - v.selectedIndex = 0 - return v, func() tea.Msg { - return v.loadCollectionsPage(v.currentPage+1, v.pageSize) - } - } - return v, nil - case "p", "left": - if v.currentPage > 1 { - v.selectedIndex = 0 - return v, func() tea.Msg { - return v.loadCollectionsPage(v.currentPage-1, v.pageSize) - } - } - return v, nil - } - } - - v.list, cmd = v.list.Update(msg) - - default: - if v.initialized { - v.list, cmd = v.list.Update(msg) - } - } - - return v, cmd -} - -func (v CollectionsView) IsFiltering() bool { - return v.initialized && v.list.IsFiltering() -} - -func (v CollectionsView) IsInitialized() bool { - return v.initialized -} - -func (v *CollectionsView) SetSelectedIndex(index int) { - v.selectedIndex = index - if v.initialized { - v.list.SetIndex(index) - } -} - -func (v CollectionsView) GetSelectedItem() *collections.CollectionEntity { - if !v.initialized { - return nil - } - if selectedItem := v.list.SelectedItem(); selectedItem != nil { - if collectionItem, ok := selectedItem.(components.CollectionItem); ok { - collection := collectionItem.GetCollection() - return &collection - } - } - return nil -} - -func (v CollectionsView) GetSelectedIndex() int { - return v.list.SelectedIndex() -} - -func (v CollectionsView) View() string { - if !v.initialized { - return v.layout.FullView( - "Collections", - "Loading collections...", - "Please wait", - ) - } - - content := v.list.View() - - // Build instructions with pagination and filter info - instructions := "↑↓: navigate • /: filter • e: edit • x: delete • q: quit" - if !v.list.IsFiltering() { - instructions = "↑↓: navigate • a: add • /: filter • e: edit • x: delete • q: quit" - } - if v.pagination.TotalPages > 1 && !v.list.IsFiltering() { - instructions += " • p/n: prev/next page" - } - - // Show dummy data notification if needed - if v.showDummyDataNotif { - instructions = lipgloss.NewStyle(). - Foreground(styles.Success). - Bold(true). - Render("✓ Demo data created! 3 collections with sample API endpoints ready to explore") - } - - return v.layout.FullView( - "Collections", - content, - instructions, - ) -} diff --git a/internal/tui/views/edit_collection.go b/internal/tui/views/edit_collection.go deleted file mode 100644 index a6c7170..0000000 --- a/internal/tui/views/edit_collection.go +++ /dev/null @@ -1,113 +0,0 @@ -package views - -import ( - "context" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/crud" - "github.com/maniac-en/req/internal/tui/components" -) - -type EditCollectionView struct { - layout components.Layout - form components.Form - collectionsManager *collections.CollectionsManager - collection collections.CollectionEntity - width int - height int - submitting bool -} - -func NewEditCollectionView(collectionsManager *collections.CollectionsManager, collection collections.CollectionEntity) EditCollectionView { - inputs := []components.TextInput{ - components.NewTextInput("Name", "Enter collection name"), - } - - inputs[0].SetValue(collection.Name) - - form := components.NewForm("Edit Collection", inputs) - form.SetSubmitText("Update") - - return EditCollectionView{ - layout: components.NewLayout(), - form: form, - collectionsManager: collectionsManager, - collection: collection, - } -} - -func (v EditCollectionView) Init() tea.Cmd { - return nil -} - -func (v EditCollectionView) Update(msg tea.Msg) (EditCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - v.form.SetSize(v.width-50, v.height-8) - - case tea.KeyMsg: - if v.submitting { - return v, nil - } - - switch msg.String() { - case "enter": - return v, func() tea.Msg { return v.submitForm() } - case "esc": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - } - - case CollectionUpdatedMsg: - return v, func() tea.Msg { return BackToCollectionsMsg{} } - - case CollectionUpdateErrorMsg: - v.submitting = false - } - - v.form, cmd = v.form.Update(msg) - return v, cmd -} - -func (v *EditCollectionView) submitForm() tea.Msg { - v.submitting = true - values := v.form.GetValues() - - if len(values) == 0 || values[0] == "" { - return CollectionUpdateErrorMsg{err: crud.ErrInvalidInput} - } - - return v.updateCollection(values[0]) -} - -func (v *EditCollectionView) updateCollection(name string) tea.Msg { - updatedCollection, err := v.collectionsManager.Update(context.Background(), v.collection.ID, name) - if err != nil { - return CollectionUpdateErrorMsg{err: err} - } - return CollectionUpdatedMsg{collection: updatedCollection} -} - -func (v EditCollectionView) View() string { - if v.submitting { - return v.layout.FullView( - "Edit Collection", - "Updating collection...", - "Please wait", - ) - } - - content := v.form.View() - instructions := "tab/↑↓: navigate • enter: update • esc: cancel" - - return v.layout.FullView( - "Edit Collection", - content, - instructions, - ) -} diff --git a/internal/tui/views/endpoint_sidebar.go b/internal/tui/views/endpoint_sidebar.go deleted file mode 100644 index 42de45d..0000000 --- a/internal/tui/views/endpoint_sidebar.go +++ /dev/null @@ -1,179 +0,0 @@ -package views - -import ( - "context" - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/tui/components" -) - -type EndpointSidebarView struct { - list components.PaginatedList - endpointsManager *endpoints.EndpointsManager - collection collections.CollectionEntity - width int - height int - initialized bool - selectedIndex int - endpoints []endpoints.EndpointEntity - focused bool -} - -func NewEndpointSidebarView(endpointsManager *endpoints.EndpointsManager, collection collections.CollectionEntity) EndpointSidebarView { - return EndpointSidebarView{ - endpointsManager: endpointsManager, - collection: collection, - selectedIndex: 0, - focused: false, - } -} - -func (v *EndpointSidebarView) Focus() { - v.focused = true -} - -func (v *EndpointSidebarView) Blur() { - v.focused = false -} - -func (v EndpointSidebarView) Focused() bool { - return v.focused -} - -func (v EndpointSidebarView) Init() tea.Cmd { - return v.loadEndpoints -} - -func (v *EndpointSidebarView) loadEndpoints() tea.Msg { - result, err := v.endpointsManager.ListByCollection(context.Background(), v.collection.ID, 100, 0) - if err != nil { - return endpointsLoadError{err: err} - } - return endpointsLoaded{ - endpoints: result.Endpoints, - } -} - -func (v EndpointSidebarView) Update(msg tea.Msg) (EndpointSidebarView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - if v.initialized { - v.list.SetSize(v.width, v.height) - } - - case endpointsLoaded: - v.endpoints = msg.endpoints - items := make([]components.ListItem, len(msg.endpoints)) - for i, endpoint := range msg.endpoints { - items[i] = components.NewEndpointItem(endpoint) - } - - title := fmt.Sprintf("Endpoints (%d)", len(msg.endpoints)) - v.list = components.NewPaginatedList(items, title) - v.list.SetIndex(v.selectedIndex) - - if v.width > 0 && v.height > 0 { - v.list.SetSize(v.width, v.height) - } - v.initialized = true - - // Auto-select first endpoint if available - if len(msg.endpoints) > 0 { - return v, func() tea.Msg { - return EndpointSelectedMsg{Endpoint: msg.endpoints[0]} - } - } - - case endpointsLoadError: - v.initialized = true - - case tea.KeyMsg: - if v.initialized { - // Forward navigation keys to the list even if not explicitly focused - oldIndex := v.list.SelectedIndex() - v.list, cmd = v.list.Update(msg) - newIndex := v.list.SelectedIndex() - - // If the selected index changed, auto-select the new endpoint - if oldIndex != newIndex && newIndex >= 0 && newIndex < len(v.endpoints) { - return v, func() tea.Msg { - return EndpointSelectedMsg{Endpoint: v.endpoints[newIndex]} - } - } - } - } - - return v, cmd -} - -func (v EndpointSidebarView) GetSelectedEndpoint() *endpoints.EndpointEntity { - if !v.initialized || len(v.endpoints) == 0 { - return nil - } - - selectedIndex := v.list.SelectedIndex() - if selectedIndex >= 0 && selectedIndex < len(v.endpoints) { - return &v.endpoints[selectedIndex] - } - return nil -} - -func (v EndpointSidebarView) GetSelectedIndex() int { - if v.initialized { - return v.list.SelectedIndex() - } - return v.selectedIndex -} - -func (v *EndpointSidebarView) SetSelectedIndex(index int) { - v.selectedIndex = index - if v.initialized { - v.list.SetIndex(index) - } -} - -func (v EndpointSidebarView) View() string { - if !v.initialized { - title := "Endpoints" - content := "Loading endpoints..." - return v.formatEmptyState(title, content) - } - if len(v.endpoints) == 0 { - title := "Endpoints (0)" - content := "No endpoints found" - return v.formatEmptyState(title, content) - } - return v.list.View() -} - -func (v EndpointSidebarView) formatEmptyState(title, content string) string { - var lines []string - lines = append(lines, title) - lines = append(lines, "") - lines = append(lines, content) - - for len(lines) < v.height-2 { - lines = append(lines, "") - } - - result := "" - for _, line := range lines { - result += line + "\n" - } - return result -} - -type endpointsLoaded struct { - endpoints []endpoints.EndpointEntity -} - -type endpointsLoadError struct { - err error -} diff --git a/internal/tui/views/endpoints-view.go b/internal/tui/views/endpoints-view.go new file mode 100644 index 0000000..dec2343 --- /dev/null +++ b/internal/tui/views/endpoints-view.go @@ -0,0 +1,140 @@ +package views + +import ( + "context" + "errors" + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/maniac-en/req/internal/backend/database" + "github.com/maniac-en/req/internal/backend/endpoints" + optionsProvider "github.com/maniac-en/req/internal/tui/components/OptionsProvider" + "github.com/maniac-en/req/internal/tui/keybinds" + "github.com/maniac-en/req/internal/tui/messages" +) + +type EndpointsView struct { + height int + collection optionsProvider.Option + width int + order int + list optionsProvider.OptionsProvider[endpoints.EndpointEntity, database.Endpoint] + manager *endpoints.EndpointsManager +} + +func (e *EndpointsView) Init() tea.Cmd { + return nil +} + +func (e *EndpointsView) Name() string { + return "Endpoints" +} + +func (e *EndpointsView) Help() []key.Binding { + return e.list.Help() +} + +func (e *EndpointsView) GetFooterSegment() string { + return fmt.Sprintf("%s/", e.collection.Name) +} + +func (e *EndpointsView) Update(msg tea.Msg) (ViewInterface, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + e.height = msg.Height + e.width = msg.Width + e.list, cmd = e.list.Update(msg) + cmds = append(cmds, cmd) + case messages.ItemAdded: + e.manager.CreateEndpoint(context.Background(), endpoints.EndpointData{ + CollectionID: e.collection.ID, + Name: msg.Item, + Method: "GET", + }) + case messages.ItemEdited: + e.manager.UpdateEndpointName(context.Background(), msg.ItemID, msg.Item) + case messages.DeleteItem: + e.manager.Delete(context.Background(), msg.ItemID) + e.list.RefreshItems() + } + + e.list, cmd = e.list.Update(msg) + cmds = append(cmds, cmd) + + return e, tea.Batch(cmds...) +} + +func (e *EndpointsView) View() string { + return e.list.View() +} + +func (e *EndpointsView) OnFocus() { + +} + +func (e *EndpointsView) SetState(items ...any) error { + if len(items) == 1 { + if collection, ok := items[0].(optionsProvider.Option); ok { + e.collection = collection + epListFunc := func(ctx context.Context) ([]endpoints.EndpointEntity, error) { + return e.manager.ListByCollection(ctx, collection.ID) + } + e.list.SetGetItemsFunc(epListFunc) + return nil + } + } + return errors.New("Invalid inputs, this function takes 1 input of type optionsProvider.Options") +} + +func (e *EndpointsView) OnBlur() { + +} + +func (e *EndpointsView) Order() int { + return e.order +} + +func itemMapperEp(items []endpoints.EndpointEntity) []list.Item { + opts := make([]list.Item, len(items)) + for i, item := range items { + newOpt := optionsProvider.Option{ + Name: item.GetName(), + Subtext: item.Method, + ID: item.GetID(), + } + opts[i] = newOpt + } + return opts +} + +func NewEndpointsView(epManager *endpoints.EndpointsManager, order int) *EndpointsView { + view := &EndpointsView{ + order: order, + collection: optionsProvider.Option{ + Name: "", + Subtext: "", + ID: 0, + }, + manager: epManager, + } + + keybinds := keybinds.NewListKeyMap() + config := defaultListConfig[endpoints.EndpointEntity, database.Endpoint](keybinds) + + epListFunc := func(ctx context.Context) ([]endpoints.EndpointEntity, error) { + return epManager.ListByCollection(ctx, view.collection.ID) + } + + config.GetItemsFunc = epListFunc + config.ItemMapper = itemMapperEp + config.AdditionalKeymaps = keybinds + config.Source = "collections" + + view.list = optionsProvider.NewOptionsProvider(config) + + return view +} diff --git a/internal/tui/views/request_builder.go b/internal/tui/views/request_builder.go deleted file mode 100644 index 893a86c..0000000 --- a/internal/tui/views/request_builder.go +++ /dev/null @@ -1,325 +0,0 @@ -package views - -import ( - "encoding/json" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type RequestBuilderTab int - -const ( - RequestBodyTab RequestBuilderTab = iota - HeadersTab - QueryParamsTab -) - -type RequestBuilder struct { - endpoint *endpoints.EndpointEntity - method string - url string - requestBody string - activeTab RequestBuilderTab - bodyTextarea components.Textarea - headersEditor components.KeyValueEditor - queryEditor components.KeyValueEditor - width int - height int - focused bool - componentFocused bool // Whether we're actually editing a component -} - -func NewRequestBuilder() RequestBuilder { - bodyTextarea := components.NewTextarea("Body", "Enter request body (JSON, text, etc.)") - headersEditor := components.NewKeyValueEditor("Headers") - queryEditor := components.NewKeyValueEditor("Query Params") - - return RequestBuilder{ - method: "GET", - url: "", - requestBody: "", - activeTab: RequestBodyTab, - bodyTextarea: bodyTextarea, - headersEditor: headersEditor, - queryEditor: queryEditor, - focused: false, - componentFocused: false, - } -} - -func (rb *RequestBuilder) SetSize(width, height int) { - rb.width = width - rb.height = height - - // Set size for body textarea (use most of available width) - // Use about 90% of available width for better JSON editing - textareaWidth := int(float64(width) * 0.9) - if textareaWidth > 120 { - textareaWidth = 120 // Cap at reasonable max width - } - if textareaWidth < 60 { - textareaWidth = 60 // Ensure minimum usable width - } - - // Set height for textarea (leave space for method/URL, tabs) - textareaHeight := height - 8 // Account for method/URL row + tabs + spacing - if textareaHeight < 5 { - textareaHeight = 5 - } - if textareaHeight > 15 { - textareaHeight = 15 // Cap at reasonable height - } - - rb.bodyTextarea.SetSize(textareaWidth, textareaHeight) - rb.headersEditor.SetSize(textareaWidth, textareaHeight) - rb.queryEditor.SetSize(textareaWidth, textareaHeight) -} - -func (rb *RequestBuilder) Focus() { - rb.focused = true - // Don't auto-focus any component - user needs to explicitly focus in - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() -} - -func (rb *RequestBuilder) Blur() { - rb.focused = false - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() -} - -func (rb RequestBuilder) Focused() bool { - return rb.focused -} - -func (rb RequestBuilder) IsEditingComponent() bool { - return rb.componentFocused -} - -func (rb *RequestBuilder) LoadFromEndpoint(endpoint endpoints.EndpointEntity) { - rb.endpoint = &endpoint - rb.method = endpoint.Method - rb.url = endpoint.Url - rb.requestBody = endpoint.RequestBody - rb.bodyTextarea.SetValue(endpoint.RequestBody) - - // Load headers from JSON - if endpoint.Headers != "" { - var headersMap map[string]string - if err := json.Unmarshal([]byte(endpoint.Headers), &headersMap); err == nil { - var headerPairs []components.KeyValuePair - for k, v := range headersMap { - headerPairs = append(headerPairs, components.KeyValuePair{ - Key: k, - Value: v, - Enabled: true, - }) - } - rb.headersEditor.SetPairs(headerPairs) - } - } - - // Load query params from JSON - if endpoint.QueryParams != "" { - var queryMap map[string]string - if err := json.Unmarshal([]byte(endpoint.QueryParams), &queryMap); err == nil { - var queryPairs []components.KeyValuePair - for k, v := range queryMap { - queryPairs = append(queryPairs, components.KeyValuePair{ - Key: k, - Value: v, - Enabled: true, - }) - } - rb.queryEditor.SetPairs(queryPairs) - } - } - - // Make sure components are not focused by default - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() - rb.componentFocused = false -} - -func (rb RequestBuilder) Update(msg tea.Msg) (RequestBuilder, tea.Cmd) { - if !rb.focused { - return rb, nil - } - - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "tab", "shift+tab": - // Only handle tab switching if not editing a component - if !rb.componentFocused { - if msg.String() == "tab" { - rb.activeTab = (rb.activeTab + 1) % 3 - } else { - rb.activeTab = (rb.activeTab + 2) % 3 // Go backwards - } - } - case "enter": - if !rb.componentFocused { - // Focus into the current tab's component for editing - rb.componentFocused = true - switch rb.activeTab { - case RequestBodyTab: - rb.bodyTextarea.Focus() - case HeadersTab: - rb.headersEditor.Focus() - case QueryParamsTab: - rb.queryEditor.Focus() - } - } - case "esc": - // Exit component editing mode - if rb.componentFocused { - rb.componentFocused = false - rb.bodyTextarea.Blur() - rb.headersEditor.Blur() - rb.queryEditor.Blur() - } - } - } - - // Only update components if we're in component editing mode - if rb.componentFocused { - switch rb.activeTab { - case RequestBodyTab: - rb.bodyTextarea, cmd = rb.bodyTextarea.Update(msg) - case HeadersTab: - rb.headersEditor, cmd = rb.headersEditor.Update(msg) - case QueryParamsTab: - rb.queryEditor, cmd = rb.queryEditor.Update(msg) - } - } - - return rb, cmd -} - -func (rb RequestBuilder) View() string { - if rb.width < 10 || rb.height < 10 { - return "Request Builder (resize window)" - } - - var sections []string - - // Method and URL row - aligned properly - methodStyle := styles.ListItemStyle.Copy(). - Background(styles.Primary). - Foreground(styles.TextPrimary). - Padding(0, 2). - Bold(true). - Height(1) - - urlStyle := styles.ListItemStyle.Copy(). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Padding(0, 2). - Width(rb.width - 20). - Height(1) - - methodView := methodStyle.Render(rb.method) - urlView := urlStyle.Render(rb.url) - methodUrlRow := lipgloss.JoinHorizontal(lipgloss.Center, methodView, " ", urlView) - sections = append(sections, methodUrlRow, "") - - // Tab headers - tabHeaders := rb.renderTabHeaders() - sections = append(sections, tabHeaders, "") - - // Tab content - tabContent := rb.renderTabContent() - sections = append(sections, tabContent) - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -func (rb RequestBuilder) renderTabHeaders() string { - tabs := []string{"Request Body", "Headers", "Query Params"} - var renderedTabs []string - - for i, tab := range tabs { - tabStyle := styles.ListItemStyle.Copy(). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary) - - if RequestBuilderTab(i) == rb.activeTab { - tabStyle = tabStyle. - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - } - - renderedTabs = append(renderedTabs, tabStyle.Render(tab)) - } - - tabsRow := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) - return tabsRow -} - -func (rb RequestBuilder) renderTabContent() string { - switch rb.activeTab { - case RequestBodyTab: - return rb.bodyTextarea.View() - case HeadersTab: - return rb.headersEditor.View() - case QueryParamsTab: - return rb.queryEditor.View() - default: - return "" - } -} - -func (rb RequestBuilder) renderPlaceholderTab(message string) string { - // Calculate the same dimensions as the textarea - textareaWidth := int(float64(rb.width) * 0.9) - if textareaWidth > 120 { - textareaWidth = 120 - } - if textareaWidth < 60 { - textareaWidth = 60 - } - - textareaHeight := rb.height - 8 - if textareaHeight < 5 { - textareaHeight = 5 - } - if textareaHeight > 15 { - textareaHeight = 15 - } - - // Create a placeholder with the same structure as textarea (no label) - containerWidth := textareaWidth - 4 // Same calculation as textarea - if containerWidth < 20 { - containerWidth = 20 - } - - container := styles.ListItemStyle.Copy(). - Width(containerWidth). - Height(textareaHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary). - Align(lipgloss.Center, lipgloss.Center) - - return container.Render(message) -} - -// Message types -type RequestSendMsg struct { - Method string - URL string - Body string -} diff --git a/internal/tui/views/selected_collection.go b/internal/tui/views/selected_collection.go deleted file mode 100644 index e505fe4..0000000 --- a/internal/tui/views/selected_collection.go +++ /dev/null @@ -1,276 +0,0 @@ -package views - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/maniac-en/req/internal/backend/collections" - "github.com/maniac-en/req/internal/backend/endpoints" - "github.com/maniac-en/req/internal/backend/http" - "github.com/maniac-en/req/internal/tui/components" - "github.com/maniac-en/req/internal/tui/styles" -) - -type MainTab int - -const ( - RequestBuilderMainTab MainTab = iota - ResponseViewerMainTab -) - -type SelectedCollectionView struct { - layout components.Layout - endpointsManager *endpoints.EndpointsManager - httpManager *http.HTTPManager - collection collections.CollectionEntity - sidebar EndpointSidebarView - selectedEndpoint *endpoints.EndpointEntity - requestBuilder RequestBuilder - activeMainTab MainTab - width int - height int - notification string -} - -func NewSelectedCollectionView(endpointsManager *endpoints.EndpointsManager, httpManager *http.HTTPManager, collection collections.CollectionEntity) SelectedCollectionView { - sidebar := NewEndpointSidebarView(endpointsManager, collection) - sidebar.Focus() // Make sure sidebar starts focused - - return SelectedCollectionView{ - layout: components.NewLayout(), - endpointsManager: endpointsManager, - httpManager: httpManager, - collection: collection, - sidebar: sidebar, - selectedEndpoint: nil, - requestBuilder: NewRequestBuilder(), - activeMainTab: RequestBuilderMainTab, - } -} - -func NewSelectedCollectionViewWithSize(endpointsManager *endpoints.EndpointsManager, httpManager *http.HTTPManager, collection collections.CollectionEntity, width, height int) SelectedCollectionView { - layout := components.NewLayout() - layout.SetSize(width, height) - - windowWidth := int(float64(width) * 0.85) - windowHeight := int(float64(height) * 0.8) - innerWidth := windowWidth - 4 - innerHeight := windowHeight - 6 - sidebarWidth := innerWidth / 4 - - sidebar := NewEndpointSidebarView(endpointsManager, collection) - sidebar.width = sidebarWidth - sidebar.height = innerHeight - sidebar.Focus() // Make sure sidebar starts focused - - requestBuilder := NewRequestBuilder() - requestBuilder.SetSize(innerWidth-sidebarWidth-1, innerHeight) - - return SelectedCollectionView{ - layout: layout, - endpointsManager: endpointsManager, - httpManager: httpManager, - collection: collection, - sidebar: sidebar, - selectedEndpoint: nil, - requestBuilder: requestBuilder, - activeMainTab: RequestBuilderMainTab, - width: width, - height: height, - } -} - -func (v SelectedCollectionView) Init() tea.Cmd { - return v.sidebar.Init() -} - -func (v SelectedCollectionView) Update(msg tea.Msg) (SelectedCollectionView, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.width = msg.Width - v.height = msg.Height - v.layout.SetSize(v.width, v.height) - - windowWidth := int(float64(v.width) * 0.85) - windowHeight := int(float64(v.height) * 0.8) - innerWidth := windowWidth - 4 - innerHeight := windowHeight - 6 - sidebarWidth := innerWidth / 4 - - v.sidebar.width = sidebarWidth - v.sidebar.height = innerHeight - v.requestBuilder.SetSize(innerWidth-sidebarWidth-1, innerHeight) - - case tea.KeyMsg: - // Clear notification on any keypress - v.notification = "" - - // If request builder is in component editing mode, only handle esc - forward everything else - if v.activeMainTab == RequestBuilderMainTab && v.requestBuilder.IsEditingComponent() { - if msg.String() == "esc" { - // Forward the Esc to request builder to exit editing mode - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - return v, builderCmd - } - // Forward all other keys to request builder when editing - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - return v, builderCmd - } - - // Normal key handling when not editing - switch msg.String() { - case "esc", "q": - return v, func() tea.Msg { return BackToCollectionsMsg{} } - case "1": - v.activeMainTab = RequestBuilderMainTab - v.requestBuilder.Focus() - case "2": - v.activeMainTab = ResponseViewerMainTab - v.requestBuilder.Blur() - case "a": - v.notification = "Adding endpoints is not yet implemented" - return v, nil - case "r": - v.notification = "Sending requests is not yet implemented" - return v, nil - } - - case EndpointSelectedMsg: - // Store the selected endpoint for display - v.selectedEndpoint = &msg.Endpoint - v.requestBuilder.LoadFromEndpoint(msg.Endpoint) - v.requestBuilder.Focus() - - case RequestSendMsg: - return v, nil - } - - // Forward messages to appropriate components (only if not editing) - if !(v.activeMainTab == RequestBuilderMainTab && v.requestBuilder.IsEditingComponent()) { - v.sidebar, cmd = v.sidebar.Update(msg) - - // Forward to request builder if it's the active tab - if v.activeMainTab == RequestBuilderMainTab { - var builderCmd tea.Cmd - v.requestBuilder, builderCmd = v.requestBuilder.Update(msg) - if builderCmd != nil { - cmd = builderCmd - } - } - } - - return v, cmd -} - -func (v SelectedCollectionView) View() string { - title := "Collection: " + v.collection.Name - if v.selectedEndpoint != nil { - title += " > " + v.selectedEndpoint.Name - } - - sidebarContent := v.sidebar.View() - - // Main tab content - var mainContent string - if v.selectedEndpoint != nil { - // Show main tabs - tabsContent := v.renderMainTabs() - tabContent := v.renderMainTabContent() - mainContent = lipgloss.JoinVertical(lipgloss.Left, tabsContent, "", tabContent) - } else { - // Check if there are no endpoints at all - if len(v.sidebar.endpoints) == 0 { - mainContent = "Create an endpoint to get started" - } else { - mainContent = "Select an endpoint from the sidebar to view details" - } - } - - if v.width < 10 || v.height < 10 { - return v.layout.FullView(title, sidebarContent, "esc/q: back to collections") - } - - windowWidth := int(float64(v.width) * 0.85) - windowHeight := int(float64(v.height) * 0.8) - innerWidth := windowWidth - innerHeight := windowHeight - 6 - - sidebarWidth := innerWidth / 4 - mainWidth := innerWidth - sidebarWidth - 1 - - // Sidebar styling - sidebarStyle := styles.SidebarStyle.Copy(). - Width(sidebarWidth). - Height(innerHeight). - BorderForeground(styles.Primary) - - mainStyle := styles.MainContentStyle.Copy(). - Width(mainWidth). - Height(innerHeight) - - content := lipgloss.JoinHorizontal( - lipgloss.Top, - sidebarStyle.Render(sidebarContent), - mainStyle.Render(mainContent), - ) - - instructions := "↑↓: navigate endpoints • a: add endpoint • 1: request • 2: response • enter: edit • esc: stop editing • r: send • esc/q: back" - if v.notification != "" { - instructions = lipgloss.NewStyle(). - Foreground(styles.Warning). - Bold(true). - Render(v.notification) - } - - return v.layout.FullView( - title, - content, - instructions, - ) -} - -func (v SelectedCollectionView) renderMainTabs() string { - tabs := []string{"Request Builder", "Response Viewer"} - var renderedTabs []string - - for i, tab := range tabs { - tabStyle := styles.ListItemStyle.Copy(). - Padding(0, 3). - Border(lipgloss.RoundedBorder()). - BorderForeground(styles.Secondary) - - if MainTab(i) == v.activeMainTab { - tabStyle = tabStyle. - Background(styles.Primary). - Foreground(styles.TextPrimary). - Bold(true) - } - - renderedTabs = append(renderedTabs, tabStyle.Render(tab)) - } - - return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) -} - -func (v SelectedCollectionView) renderMainTabContent() string { - switch v.activeMainTab { - case RequestBuilderMainTab: - return v.requestBuilder.View() - case ResponseViewerMainTab: - return styles.ListItemStyle.Copy(). - Width(v.width/2). - Height(v.height/2). - Align(lipgloss.Center, lipgloss.Center). - Render("Yet to be implemented...") - default: - return "" - } -} - -// Message types for selected collection view -type EndpointSelectedMsg struct { - Endpoint endpoints.EndpointEntity -} diff --git a/internal/tui/views/types.go b/internal/tui/views/types.go new file mode 100644 index 0000000..465b14d --- /dev/null +++ b/internal/tui/views/types.go @@ -0,0 +1,19 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type ViewInterface interface { + Init() tea.Cmd + Name() string + Help() []key.Binding + GetFooterSegment() string + Update(tea.Msg) (ViewInterface, tea.Cmd) + View() string + Order() int + SetState(...any) error + OnFocus() + OnBlur() +} diff --git a/main.go b/main.go index 8d527cc..90bd251 100644 --- a/main.go +++ b/main.go @@ -149,7 +149,7 @@ func main() { if err != nil { log.Error("failed to populate dummy data", "error", err) } else if dummyDataCreated { - appContext.SetDummyDataCreated(true) + // appContext.SetDummyDataCreated(true) } log.Info("application initialized", "components", []string{"database", "collections", "endpoints", "http", "history", "logging", "demo"}) @@ -157,7 +157,7 @@ func main() { log.Info("application started successfully") // Entry point for UI - program := tea.NewProgram(app.NewModel(appContext), tea.WithAltScreen()) + program := tea.NewProgram(app.NewAppModel(appContext), tea.WithAltScreen()) if _, err := program.Run(); err != nil { log.Fatal("Fatal error:", err) }