├── .github
├── FUNDING.yml
└── workflows
│ └── release.yml
├── assets
└── autocomplete.webp
├── keybindings.example.json
├── internal
├── ai
│ ├── driver.go
│ ├── openai_driver.go
│ └── anthropic_driver.go
├── util
│ ├── clipboard.go
│ ├── json.go
│ ├── json_test.go
│ ├── mongo_test.go
│ ├── mongo.go
│ ├── encrypt.go
│ ├── encrypt_test.go
│ └── config.go
├── tui
│ ├── core
│ │ ├── form.go
│ │ ├── pages.go
│ │ ├── table.go
│ │ ├── app.go
│ │ ├── element.go
│ │ └── others.go
│ ├── modal
│ │ ├── info.go
│ │ ├── error.go
│ │ ├── confirm.go
│ │ ├── server_info.go
│ │ ├── style_change.go
│ │ ├── query_options.go
│ │ ├── inline_edit.go
│ │ └── history.go
│ ├── component
│ │ ├── tab_bar.go
│ │ ├── peeker.go
│ │ ├── header.go
│ │ ├── database.go
│ │ ├── ai_query.go
│ │ └── input_bar.go
│ ├── primitives
│ │ ├── input_modal.go
│ │ ├── list_modal.go
│ │ ├── form_modal.go
│ │ └── view_modal_test.go
│ ├── page
│ │ ├── help.go
│ │ ├── welcome.go
│ │ └── main.go
│ └── app.go
├── mongo
│ ├── setup.go
│ ├── mongo.go
│ ├── id.go
│ ├── id_test.go
│ ├── state.go
│ ├── parser.go
│ └── state_test.go
├── manager
│ └── element.go
└── config
│ └── styles
│ ├── default.yaml
│ ├── dracula.yaml
│ ├── dark-blue.yaml
│ ├── light.yaml
│ └── light-forest.yaml
├── main.go
├── packages
└── aur
│ ├── .SRCINFO
│ └── PKGBUILD
├── release-notes
├── v0.1.22.md
├── v0.1.24.md
├── v0.1.21.md
├── v0.1.27.md
├── v0.1.29.md
├── v0.1.25.md
├── v0.1.20.md
├── v0.1.23.md
├── v0.1.31.md
├── v0.1.30.md
├── v0.1.26.md
├── v0.1.32.md
└── v0.1.28.md
├── .gitignore
├── Makefile
├── cmd
└── cmd_test.go
├── go.mod
├── .goreleaser.yaml
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [kopecmaciej]
2 |
--------------------------------------------------------------------------------
/assets/autocomplete.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kopecmaciej/vi-mongo/HEAD/assets/autocomplete.webp
--------------------------------------------------------------------------------
/keybindings.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "global": {
3 | "toggleFullScreenHelp": {
4 | "keys": [
5 | "Ctrl+P"
6 | ],
7 | "runes": [
8 | "?"
9 | ],
10 | "description": "Toggle full screen help"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/internal/ai/driver.go:
--------------------------------------------------------------------------------
1 | package ai
2 |
3 | type AIDriver interface {
4 | SetSystemMessage(message string)
5 | GetResponse(prompt string, model string) (string, error)
6 | }
7 |
8 | func GetAiModels() ([]string, int) {
9 | openaiModels, openaiDefaultModel := GetGptModels()
10 | anthropicModels, _ := GetAnthropicModels()
11 | return append(openaiModels, anthropicModels...), openaiDefaultModel
12 | }
13 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/kopecmaciej/vi-mongo/cmd"
8 | )
9 |
10 | func main() {
11 | defer func() {
12 | if r := recover(); r != nil {
13 | fmt.Fprintf(os.Stderr, "\nERROR: Application crashed unexpectedly: %v\n", r)
14 | fmt.Fprintf(os.Stderr, "Please check the log file for details\n")
15 | os.Exit(1)
16 | }
17 | }()
18 |
19 | err := cmd.Execute()
20 | if err != nil {
21 | os.Exit(1)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/aur/.SRCINFO:
--------------------------------------------------------------------------------
1 | pkgbase = vi-mongo
2 | pkgdesc = Terminal User Interface for MongoDB
3 | pkgver = 0.1.22
4 | pkgrel = 1
5 | url = https://github.com/kopecmaciej/vi-mongo
6 | arch = x86_64
7 | license = Apache-2.0
8 | makedepends = go
9 | depends = glibc
10 | options = !debug
11 | source = vi-mongo-0.1.22.tar.gz::https://github.com/kopecmaciej/vi-mongo/archive/v0.1.22.tar.gz
12 | sha256sums = 9d628ffc0250b156a96d22501f176d166af1f02437f34b37ce5babcdddb7d133
13 |
14 | pkgname = vi-mongo
15 |
--------------------------------------------------------------------------------
/release-notes/v0.1.22.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.22
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 | - Ability to add multiple fields (compound index):
9 | - Added new button for adding additional fields
10 | - TTS is now disabled after new field is added
11 |
12 | ### Bug Fixes
13 | - Fixed the issue of the R key being doubly assigned to both resetting hidden columns and refreshing documents. (#54)
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | .*
6 | !.gitignore
7 | !.github
8 | !.goreleaser.yaml
9 |
10 | # Dependency directories (remove the comment below to include it)
11 | # vendor/
12 |
13 | # Go workspace file
14 | go.work
15 | history.txt
16 | config.yaml
17 | keybindings.json
18 | event.json
19 | commands.md
20 | dist/
21 |
22 |
--------------------------------------------------------------------------------
/release-notes/v0.1.24.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.24
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Limit in configuration**
10 | - From now limit can be set in connection configuration (request from issue #60)
11 | - It can be overwritten by Query Option modal
12 |
13 | - **Improved documentation**
14 | - Documentation on vi-mongo.com was improved, configuration and usage page was rewrite
15 |
16 | ## Thank You
17 |
18 | Thank you to everyone who helped improve this project!
19 |
--------------------------------------------------------------------------------
/internal/util/clipboard.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/atotto/clipboard"
7 | "github.com/rs/zerolog/log"
8 | )
9 |
10 | func GetClipboard() (func(string), func() string) {
11 | cpFunc := func(text string) {
12 | err := clipboard.WriteAll(text)
13 | if err != nil {
14 | log.Error().Err(err).Msg("Error writing to clipboard")
15 | }
16 | }
17 | pasteFunc := func() string {
18 | text, err := clipboard.ReadAll()
19 | if err != nil {
20 | log.Error().Err(err).Msg("Error reading from clipboard")
21 | return ""
22 | }
23 | return strings.TrimSpace(text)
24 | }
25 |
26 | return cpFunc, pasteFunc
27 | }
28 |
--------------------------------------------------------------------------------
/release-notes/v0.1.21.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.21
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Bug Fixes
8 | - Replaced custom parsing with BSON marshal/unmarshal system (#53)
9 | - Fixed tests and removed unnecessary logic
10 |
11 | ### Other Changes
12 | - Improved error logging and handling
13 | - Added logs closer to error sources for better debugging
14 | - Removed logging from modal
15 | - Added new style improvements
16 | - Code refactoring and general improvements
17 |
18 | ## Thank You
19 | Thank you to everyone who helped improve this project!
20 |
21 |
--------------------------------------------------------------------------------
/release-notes/v0.1.27.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.27
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Breaking Changes
8 |
9 | - **Reverse alwaysConfirmActions behavior **
10 |
11 | To protect users from accidental deletion/duplication without confirmation, `alwaycConfirmActions` will be set to true by default. Only explicitly changing this value in `config.yaml` to false will make it work.
12 |
13 | ```yaml
14 | - url: mongodb://localhost:27017
15 | host: localhost
16 | options:
17 | alwaysConfirmActions: false
18 | ```
19 |
20 | ## Thank You
21 |
22 | Thank you to everyone who helped improve this project!
23 |
--------------------------------------------------------------------------------
/release-notes/v0.1.29.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.29
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Add Edit Modal**: Introduced a new modal for editing documents without leaving the current view. Modal is triggered by `e` key. [#69](https://github.com/kopecmaciej/vi-mongo/issues/69)
10 | - **Jump into DB/Collection Changes**: Added cli flag for jumping into databases and collections `-j` or `--jump` (format: db-name/collection-name) [#77](https://github.com/kopecmaciej/vi-mongo/issues/77)
11 | - **Multiple Selected Row Colors**: Implemented visual enhancements for selecting multiple rows. In style.yaml it can be configured with `multiSelectedRowColor` in `content` section.
12 |
13 | ### Contributions
14 |
15 | - **Custom Base URL for AI/OpenAI Driver**: Added support for custom base URLs in the OpenAI driver [#78](https://github.com/kopecmaciej/vi-mongo/pull/78)
16 |
17 |
18 | ## Thank You
19 |
20 | Thank you to everyone who helped improve this project!
--------------------------------------------------------------------------------
/release-notes/v0.1.25.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.25
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Enhanced CLI Support**
10 | - Added new flags to connect directly from command line:
11 | - `-l, --connection-list`: List all available connections
12 | - `-n, --connection-name string`: Connect to a specific MongoDB connection by name
13 | - Changed `--connection-page` short flag from `-n` to `-p` to avoid conflicts
14 |
15 | - **Improved Navigation**
16 | - Added `0` and `$` for horizontal movement to first and last column in a row
17 |
18 | - **Performance Improvements**
19 | - `Count` query now runs in background, resulting in much faster response when clicking on collections
20 |
21 | ### Bug Fixes
22 |
23 | - Fixed cursor placement after document deletion (cursor now stays in place of deleted document)
24 | - Fixed `ctrl+d` and `ctrl+u` movement behavior - now strictly vertical movement, staying in the same column
25 |
26 | ## Thank You
27 |
28 | Thank you to everyone who helped improve this project!
29 |
--------------------------------------------------------------------------------
/release-notes/v0.1.20.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.20
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 | - **Hiding/showing given columns** (feature request #37)
9 | - Added ability to hide/show columns in table view
10 | - New keybinding `H` to hide the current column in given collection & database
11 | - New keybinding `R` to reset hidden columns only from current collection & database
12 | - **Quick Column Sorting**
13 | - Added new keybinding `S` to immediately sort the selected column
14 | - Toggles between ascending (1) and descending (-1) order
15 | - Quick note: If given column has a lot of null values there is a chance that it will
16 | dissapear from the view as there will be no values to show
17 |
18 | ### Bug Fixes
19 | - Improved error handling while showing server info modal
20 | - Small fixes in error modal
21 |
22 | ### Other Changes
23 | - Updated AUR package version information
24 | - Code refactoring and improvements to state management
25 |
26 | ## Contributors
27 | Thank you to all contributors who helped make this release possible!
28 |
29 | - me
--------------------------------------------------------------------------------
/internal/tui/core/form.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/kopecmaciej/tview"
5 | "github.com/kopecmaciej/vi-mongo/internal/config"
6 | )
7 |
8 | type Form struct {
9 | *tview.Form
10 | }
11 |
12 | func NewForm() *Form {
13 | return &Form{
14 | Form: tview.NewForm(),
15 | }
16 | }
17 |
18 | func (f *Form) SetStyle(style *config.Styles) {
19 | SetCommonStyle(f.Form, style)
20 | f.SetButtonBackgroundColor(style.Others.ButtonsBackgroundColor.Color())
21 | f.SetButtonTextColor(style.Others.ButtonsTextColor.Color())
22 | }
23 |
24 | // This function will not include buttons, so if there are any
25 | // should be added separatly
26 | func (f *Form) InsertFormItem(pos int, item tview.FormItem) *Form {
27 | count := f.GetFormItemCount()
28 | if pos < 0 || pos > count {
29 | pos = count
30 | }
31 |
32 | existingItems := make([]tview.FormItem, count)
33 | for i := 0; i < count; i++ {
34 | existingItems[i] = f.GetFormItem(i)
35 | }
36 |
37 | f.Clear(true)
38 | for i := 0; i < pos; i++ {
39 | f.AddFormItem(existingItems[i])
40 | }
41 | f.AddFormItem(item)
42 | for i := pos; i < count; i++ {
43 | f.AddFormItem(existingItems[i])
44 | }
45 |
46 | return f
47 | }
48 |
--------------------------------------------------------------------------------
/release-notes/v0.1.23.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.23
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **New QueryOptions Modal**
10 | - Added Projection, Limit and Skip fields for refined queries
11 | - Accessible via the default keybinding `Alt+o`
12 | - To change this add this in configuration:
13 |
14 | ```
15 | {
16 | "content": {
17 | "toggleQueryOptions": {
18 | "keys": [
19 | "Alt+O"
20 | ],
21 | "description": "Toggle query options"
22 | }
23 | }
24 | }
25 | ```
26 |
27 | ### Bug Fixes
28 |
29 | - Fixed Content table header display
30 | - Pages now show as `currentPage/maxPage`
31 | - Skip value is displayed in parentheses right after `Page`
32 |
33 | ### Breaking Changes
34 |
35 | - **Modified Alt+key behavior**
36 | - Fixed inconsistency with Alt+key combinations
37 | - Alt+Rune[key] no longer works; Alt+key format is now required
38 | - **Note**: Users with custom keybindings in YAML configuration files will
39 | need to update their settings, so this
40 |
41 | ## Thank You
42 |
43 | Thank you to everyone who helped improve this project!
44 |
--------------------------------------------------------------------------------
/release-notes/v0.1.31.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.31
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Bug Fixes
8 |
9 | - **Config Flag Handling**: Fixed issues with config flag loading and updating, ensuring configuration changes are properly persisted and applied [#95](https://github.com/kopecmaciej/vi-mongo/issues/95)
10 | - **Single-Item Arrays Display**: Fixed bug where single-item arrays were not displaying correctly in table view, improving data visualization [#94](https://github.com/kopecmaciej/vi-mongo/issues/94)
11 | - **AI Query Keybinding**: Changed default key for AI query feature to avoid collisions with tview internal keybindings, preventing unintended behaviour
12 | - **Connection Page Save Button**: Fixed cancel/save button functionality in connection page to work correctly when creating or updating connections
13 |
14 | ### Technical Improvements
15 |
16 | - **Config Utilities**: Added new utility functions for config file management with comprehensive test coverage
17 | - **Enhanced Testing**: Added extensive test coverage for config handling
18 |
19 | ## Thank You
20 |
21 | Thank you to everyone who helped improve this project!
22 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - master
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | - name: Set up Go
18 | uses: actions/setup-go@v5
19 | with:
20 | go-version: "1.23"
21 | - name: Install dependencies
22 | run: go get .
23 | - name: Test with the Go CLI
24 | run: go test ./...
25 |
26 | ## manual releases for now
27 | # goreleaser:
28 | # if: github.event_name == 'workflow_dispatch'
29 | # runs-on: ubuntu-latest
30 | # steps:
31 | # - name: Checkout
32 | # uses: actions/checkout@v4
33 | # with:
34 | # fetch-depth: 0
35 | # - name: Set up Go
36 | # uses: actions/setup-go@v5
37 | # with:
38 | # go-version: "1.23"
39 | # - name: Run GoReleaser
40 | # uses: goreleaser/goreleaser-action@v4
41 | # with:
42 | # distribution: goreleaser
43 | # version: latest
44 | # args: release --clean
45 | # env:
46 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | BUILD_DIR := .build
2 | SVC_NAME := vi-mongo
3 | REPOSITORY := github.com/kopecmaciej/vi-mongo
4 | VERSION ?= $(shell git describe --tags --always --dirty)
5 |
6 | .PHONY: build run
7 |
8 | all: build run
9 |
10 | build:
11 | go build -ldflags="-s -w -X $(REPOSITORY)/cmd.version=$(VERSION)" -o $(BUILD_DIR)/$(SVC_NAME) .
12 |
13 | run:
14 | env $$(cat .env) $(BUILD_DIR)/$(SVC_NAME)
15 |
16 | test:
17 | go test ./...
18 |
19 | test-verbose:
20 | go test -v ./...
21 |
22 | debug:
23 | if [ -f /proc/sys/kernel/yama/ptrace_scope ]; then \
24 | sudo sysctl kernel.yama.ptrace_scope=0; \
25 | fi
26 | go build -gcflags="all=-N -l" -o $(BUILD_DIR)/$(SVC_NAME) .
27 | $(BUILD_DIR)/$(SVC_NAME)
28 |
29 | lint:
30 | golangci-lint run
31 |
32 | # Release with GoReleaser using the latest tag
33 | release:
34 | @if [ ! -f "./release-notes/$(VERSION).md" ]; then \
35 | echo "Error: Release notes not found for $(VERSION)"; \
36 | echo "Expected file: ./release-notes/$(VERSION).md"; \
37 | exit 1; \
38 | fi
39 | goreleaser release --release-notes ./release-notes/$(VERSION).md --clean
40 |
41 | # Snapshot release (without requiring release notes)
42 | snapshot:
43 | goreleaser release --snapshot --clean
44 |
45 | bump-version:
46 | @git describe --tags --abbrev=0 | awk -F. '{OFS="."; $NF+=1; print $0}'
47 |
--------------------------------------------------------------------------------
/packages/aur/PKGBUILD:
--------------------------------------------------------------------------------
1 | # Maintainer: Maciej Kopeć
2 | pkgname=vi-mongo
3 | pkgver=0.1.22
4 | pkgrel=1
5 | pkgdesc="Terminal User Interface for MongoDB"
6 | arch=('x86_64')
7 | url="https://github.com/kopecmaciej/vi-mongo"
8 | license=("Apache-2.0")
9 | depends=('glibc')
10 | makedepends=('go')
11 | source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
12 | sha256sums=('9d628ffc0250b156a96d22501f176d166af1f02437f34b37ce5babcdddb7d133')
13 | options=('!debug')
14 |
15 | prepare() {
16 | cd "$pkgname-$pkgver"
17 | GOFLAGS="-mod=readonly" go mod vendor
18 | }
19 |
20 | build() {
21 | cd "$pkgname-$pkgver"
22 | export CGO_LDFLAGS="$LDFLAGS"
23 | export CGO_CFLAGS="$CFLAGS"
24 | export CGO_CXXFLAGS="$CXXFLAGS"
25 | export CGO_CPPFLAGS="$CPPFLAGS"
26 | export GOFLAGS="-buildmode=pie -mod=vendor -modcacherw"
27 | export GOPATH="$srcdir"
28 |
29 | local ld_flags=" \
30 | -compressdwarf=false \
31 | -linkmode=external \
32 | -X github.com/kopecmaciej/vi-mongo/cmd.version=v$pkgver"
33 |
34 | go build -ldflags="$ld_flags" -o "$pkgname"
35 | }
36 |
37 | check() {
38 | cd "$pkgname-$pkgver"
39 | go test ./...
40 | }
41 |
42 | package() {
43 | cd "$pkgname-$pkgver"
44 | install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
45 | install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
46 | }
47 |
--------------------------------------------------------------------------------
/internal/tui/modal/info.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
7 | )
8 |
9 | const (
10 | InfoModalId = "Info"
11 | )
12 |
13 | func NewInfo(message string) *tview.Modal {
14 | message = "[White::b] " + message + " [::]"
15 |
16 | infoModal := tview.NewModal()
17 | infoModal.SetTitle(" Info ")
18 | infoModal.SetBorderPadding(0, 0, 1, 1)
19 | infoModal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
20 | infoModal.SetTextColor(tcell.ColorGreen)
21 | infoModal.SetText(message)
22 | infoModal.AddButtons([]string{"Ok"})
23 |
24 | return infoModal
25 | }
26 |
27 | // ShowInfo shows a modal with an informational message
28 | func ShowInfo(page *core.Pages, message string) {
29 | infoModal := NewInfo(message)
30 |
31 | infoModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
32 | if buttonLabel == "Ok" {
33 | page.RemovePage(InfoModalId)
34 | }
35 | })
36 | page.AddPage(InfoModalId, infoModal, true, true)
37 | }
38 |
39 | func ShowInfoModalAndFocus(page *core.Pages, message string, setFocus func()) {
40 | infoModal := NewInfo(message)
41 | infoModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
42 | if buttonLabel == "Ok" {
43 | page.RemovePage(InfoModalId)
44 | setFocus()
45 | }
46 | })
47 | page.AddPage(InfoModalId, infoModal, true, true)
48 | }
49 |
--------------------------------------------------------------------------------
/internal/util/json.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "strings"
5 | "unicode"
6 | )
7 |
8 | // IsJsonEmpty checks if a JSON string is empty or only contains whitespace
9 | func IsJsonEmpty(s string) bool {
10 | s = strings.ReplaceAll(s, " ", "")
11 | return s == "" || s == "{}"
12 | }
13 |
14 | // CleanJsonWhitespaces removes new lines and redundant spaces from a JSON string
15 | // and also removes comma from the end of the string
16 | func CleanJsonWhitespaces(s string) string {
17 | s = strings.TrimSuffix(s, ",")
18 | s = strings.ReplaceAll(s, "\n", "")
19 | s = strings.ReplaceAll(s, "\t", "")
20 |
21 | var result strings.Builder
22 | inQuotes := false
23 | prevChar := ' '
24 |
25 | // remove whitespace from a JSON string, except within quotes
26 | for _, char := range s {
27 | if char == '"' && prevChar != '\\' {
28 | inQuotes = !inQuotes
29 | }
30 |
31 | if inQuotes {
32 | result.WriteRune(char)
33 | } else if !unicode.IsSpace(char) {
34 | result.WriteRune(char)
35 | } else if unicode.IsSpace(char) && prevChar != ' ' {
36 | result.WriteRune(char)
37 | }
38 |
39 | prevChar = char
40 | }
41 |
42 | return result.String()
43 | }
44 |
45 | // CleanAllWhitespaces removes all whitespaces from a string
46 | func CleanAllWhitespaces(s string) string {
47 | s = strings.ReplaceAll(s, " ", "")
48 | s = strings.ReplaceAll(s, "\n", "")
49 | s = strings.ReplaceAll(s, "\t", "")
50 | return s
51 | }
52 |
--------------------------------------------------------------------------------
/internal/mongo/setup.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/kopecmaciej/vi-mongo/internal/config"
8 | "github.com/kopecmaciej/vi-mongo/internal/util"
9 |
10 | "go.mongodb.org/mongo-driver/mongo"
11 | "go.mongodb.org/mongo-driver/mongo/options"
12 | )
13 |
14 | type Client struct {
15 | Client *mongo.Client
16 | Config *config.MongoConfig
17 | }
18 |
19 | func NewClient(config *config.MongoConfig) *Client {
20 | return &Client{
21 | Config: config,
22 | }
23 | }
24 |
25 | func (m *Client) Connect() error {
26 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(m.Config.Timeout)*time.Second)
27 | defer cancel()
28 |
29 | uri := m.Config.GetUri()
30 | if m.Config.Password != "" && config.EncryptionKey != "" {
31 | password, err := util.DecryptPassword(m.Config.Password, config.EncryptionKey)
32 | if err != nil {
33 | return err
34 | }
35 | uri = util.RestorePasswordInUri(uri, password)
36 |
37 | }
38 | opts := options.Client().ApplyURI(uri)
39 | client, err := mongo.Connect(ctx, opts)
40 | if err != nil {
41 | return err
42 | }
43 |
44 | m.Client = client
45 |
46 | return nil
47 | }
48 |
49 | func (m *Client) Close(ctx context.Context) {
50 | m.Client.Disconnect(ctx)
51 | }
52 |
53 | func (m *Client) Ping() error {
54 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(m.Config.Timeout)*time.Second)
55 | defer cancel()
56 | return m.Client.Ping(ctx, nil)
57 | }
58 |
--------------------------------------------------------------------------------
/release-notes/v0.1.30.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.30
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Update Current Connection**: Added ability to update existing MongoDB connections without creating new ones. This improves workflow efficiency when connection details change. [#89](https://github.com/kopecmaciej/vi-mongo/issues/89)
10 | - **Enhanced URI Input**: Improved connection page by converting URI input to a text area, allowing for better visibility and editing of longer connection strings
11 | - **Current Version Display**: Configuration files now always show the current application version for better tracking and debugging
12 |
13 | ### Bug Fixes
14 |
15 | - **Binary ID Handling**: Fixed issues with binary ObjectIDs in MongoDB documents, improving compatibility with various MongoDB data types [#88](https://github.com/kopecmaciej/vi-mongo/issues/88)
16 | - **Connection Page UX**: Removed Enter key from keybindings in connection page and fixed cancel button functionality for better user experience
17 | - **Welcome Page Styling**: Fixed styling issues in the welcome page for consistent visual appearance
18 |
19 | ### Technical Improvements
20 |
21 | - **Brew Formula**: Added temporary Homebrew formula for easier installation on macOS systems
22 | - **Repository Cleanup**: Moved formula to separate repository for better maintainability
23 |
24 | ## Thank You
25 |
26 | Thank you to everyone who helped improve this project!
27 |
--------------------------------------------------------------------------------
/release-notes/v0.1.26.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.26
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Improved Document Management**
10 | - Added new keystrokes for faster document operations:
11 | - `Alt+d`: Delete document without confirmation (configurable via
12 | `deleteDocumentNoConfirm`)
13 | - `Alt+D`: Duplicate document without confirmation (configurable via
14 | `duplicateDocumentNoConfirm`)
15 |
16 | - **Enhanced Configuration Options**
17 | - Added new `alwaysConfirmActions` flag to configuration:
18 | ```yaml
19 | - url: mongodb://localhost:27017
20 | host: localhost
21 | port: 27017
22 | database: ""
23 | username: ""
24 | password: ""
25 | name: localhost
26 | timeout: 5
27 | options:
28 | alwaysConfirmActions: true
29 | ```
30 | - When set to `true`, this option requires confirmation for all document
31 | operations (`duplicateDocumentNoConfirm` and `deleteDocumentNoConfirm` are
32 | not working)
33 | - `false` or not set at all allows fast operations with default `Alt+d`
34 | (`deleteDocumentNoConfirm`) and `Alt+D` (`duplicateDocumentNoConfirm`)
35 |
36 | ### Bug Fixes
37 |
38 | - Fixed pasting issues in connection page
39 | - Added proper paste support with `Ctrl+v` to avoid terminal pasting problems
40 | (with `Ctrl+Shift+v`)
41 |
42 | ## Thank You
43 |
44 | Thank you to everyone who helped improve this project!
45 |
--------------------------------------------------------------------------------
/internal/mongo/mongo.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "go.mongodb.org/mongo-driver/bson"
5 | "go.mongodb.org/mongo-driver/bson/primitive"
6 | )
7 |
8 | // ServerStatus are the values chosen from the serverStatus command
9 | type ServerStatus struct {
10 | Ok int32 `bson:"ok"`
11 | Version string `bson:"version"`
12 | Uptime int32 `bson:"uptime"`
13 | CurrentConns int32 `bson:"connections.current"`
14 | AvailableConns int32 `bson:"connections.available"`
15 | OpCounters struct {
16 | Insert int32 `bson:"insert"`
17 | Query int32 `bson:"query"`
18 | Update int32 `bson:"update"`
19 | Delete int32 `bson:"delete"`
20 | } `bson:"opcounters"`
21 | Mem struct {
22 | Resident int32 `bson:"resident"`
23 | Virtual int32 `bson:"virtual"`
24 | } `bson:"mem"`
25 | Repl struct {
26 | ReadOnly bool `bson:"readOnly"`
27 | IsMaster bool `bson:"ismaster"`
28 | } `bson:"repl"`
29 | }
30 |
31 | // DBsWithCollections is a object used to store the database name and its collections
32 | type DBsWithCollections struct {
33 | DB string
34 | Collections []string
35 | }
36 |
37 | // IndexInfo represents the combined information about an index from multiple commands
38 | type IndexInfo struct {
39 | Name string
40 | Definition bson.M
41 | Type string
42 | Size string
43 | Usage string
44 | Properties []string
45 | }
46 |
47 | // indexStats represents the data that is returned by the indexStats command
48 | type indexStats struct {
49 | Size string
50 | Accesses primitive.M
51 | }
52 |
--------------------------------------------------------------------------------
/cmd/cmd_test.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | )
7 |
8 | func TestValidateDirectNavigateFormat(t *testing.T) {
9 | tests := []struct {
10 | name string
11 | input string
12 | wantErr bool
13 | errMsg string
14 | }{
15 | {
16 | name: "valid format with hyphens",
17 | input: "my-db/my-collection",
18 | wantErr: false,
19 | },
20 | {
21 | name: "invalid - missing collection",
22 | input: "mydb/",
23 | wantErr: true,
24 | errMsg: "both db-name and collection-name are required",
25 | },
26 | {
27 | name: "invalid - missing database",
28 | input: "/mycollection",
29 | wantErr: true,
30 | errMsg: "both db-name and collection-name are required",
31 | },
32 | {
33 | name: "invalid - too many slashes",
34 | input: "mydb/mycollection/extra",
35 | wantErr: true,
36 | errMsg: "format should be db-name/collection-name",
37 | },
38 | }
39 |
40 | for _, tt := range tests {
41 | t.Run(tt.name, func(t *testing.T) {
42 | err := validateDirectNavigateFormat(tt.input)
43 | if tt.wantErr {
44 | if err == nil {
45 | t.Errorf("validateDirectNavigateFormat() error = nil, wantErr %v", tt.wantErr)
46 | return
47 | }
48 | if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
49 | t.Errorf("validateDirectNavigateFormat() error = %v, want error containing %v", err, tt.errMsg)
50 | }
51 | } else {
52 | if err != nil {
53 | t.Errorf("validateDirectNavigateFormat() error = %v, wantErr %v", err, tt.wantErr)
54 | }
55 | }
56 | })
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/ai/openai_driver.go:
--------------------------------------------------------------------------------
1 | package ai
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/rs/zerolog/log"
8 | "github.com/sashabaranov/go-openai"
9 | )
10 |
11 | type OpenAIDriver struct {
12 | client *openai.Client
13 | systemMessage string
14 | }
15 |
16 | func NewOpenAIDriver(apiKey string, apiUrl string) *OpenAIDriver {
17 | openAiClientCfg := openai.DefaultConfig(apiKey)
18 | if apiUrl != "" {
19 | openAiClientCfg.BaseURL = apiUrl
20 | }
21 | openAiClient := openai.NewClientWithConfig(openAiClientCfg)
22 | return &OpenAIDriver{
23 | client: openAiClient,
24 | }
25 | }
26 |
27 | func (d *OpenAIDriver) SetSystemMessage(message string) {
28 | d.systemMessage = message
29 | }
30 |
31 | func (d *OpenAIDriver) GetResponse(prompt string, model string) (string, error) {
32 | resp, err := d.client.CreateChatCompletion(
33 | context.Background(),
34 | openai.ChatCompletionRequest{
35 | Model: model,
36 | Messages: []openai.ChatCompletionMessage{
37 | {
38 | Role: openai.ChatMessageRoleSystem,
39 | Content: d.systemMessage,
40 | },
41 | {
42 | Role: openai.ChatMessageRoleUser,
43 | Content: prompt,
44 | },
45 | },
46 | },
47 | )
48 |
49 | if err != nil {
50 | log.Error().Err(err).Msg("Failed to create chat completion")
51 | return "", fmt.Errorf("failed to create chat completion: %w", err)
52 | }
53 |
54 | return resp.Choices[0].Message.Content, nil
55 | }
56 |
57 | func GetGptModels() ([]string, int) {
58 | models := []string{"gpt-3.5-turbo", "gpt-4o", "gpt-4o-mini"}
59 | defaultModelIndex := 2
60 | return models, defaultModelIndex
61 | }
62 |
--------------------------------------------------------------------------------
/internal/tui/modal/error.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
7 | )
8 |
9 | const (
10 | ErrorModalId = "Error"
11 | )
12 |
13 | func NewError(message string, err error) *tview.Modal {
14 | taggedMessage := "[White::b] " + message + " [::]"
15 |
16 | if err != nil {
17 | errMsg := err.Error()
18 | if errMsg != "" {
19 | if len(errMsg) > 240 {
20 | errMsg = errMsg[:240] + " ..."
21 | }
22 | taggedMessage += "\n" + errMsg
23 | }
24 | }
25 |
26 | errModal := tview.NewModal()
27 | errModal.SetTitle(" Error ")
28 | errModal.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
29 | errModal.SetTextColor(tcell.ColorRed)
30 | errModal.SetText(taggedMessage)
31 | errModal.AddButtons([]string{"Ok"})
32 |
33 | return errModal
34 | }
35 |
36 | // ShowError shows a modal with an error message
37 | // and logs the error if it's passed
38 | func ShowError(page *core.Pages, message string, err error) {
39 | errModal := NewError(message, err)
40 |
41 | errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
42 | if buttonLabel == "Ok" {
43 | page.RemovePage(ErrorModalId)
44 | }
45 | })
46 | page.AddPage(ErrorModalId, errModal, true, true)
47 | }
48 |
49 | func ShowErrorAndSetFocus(page *core.Pages, message string, err error, setFocus func()) {
50 | errModal := NewError(message, err)
51 | errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
52 | if buttonLabel == "Ok" {
53 | page.RemovePage(ErrorModalId)
54 | setFocus()
55 | }
56 | })
57 | page.AddPage(ErrorModalId, errModal, true, true)
58 | }
59 |
--------------------------------------------------------------------------------
/internal/tui/core/pages.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/config"
7 | "github.com/kopecmaciej/vi-mongo/internal/manager"
8 | )
9 |
10 | type Pages struct {
11 | *tview.Pages
12 |
13 | manager *manager.ElementManager
14 | app *App
15 | }
16 |
17 | func (p *Pages) SetStyle(style *config.Styles) {
18 | p.Pages.SetBackgroundColor(style.Global.BackgroundColor.Color())
19 | p.Pages.SetBorderColor(style.Global.BorderColor.Color())
20 | p.Pages.SetTitleColor(style.Global.TitleColor.Color())
21 | p.Pages.SetFocusStyle(tcell.StyleDefault.Foreground(style.Global.FocusColor.Color()).Background(style.Global.BackgroundColor.Color()))
22 | }
23 |
24 | func NewPages(manager *manager.ElementManager, app *App) *Pages {
25 | return &Pages{
26 | Pages: tview.NewPages(),
27 | manager: manager,
28 | app: app,
29 | }
30 | }
31 |
32 | // AddPage is a wrapper for tview.Pages.AddPage
33 | func (r *Pages) AddPage(view tview.Identifier, page tview.Primitive, resize, visable bool) *tview.Pages {
34 | r.app.SetPreviousFocus()
35 | r.Pages.AddPage(string(view), page, resize, visable)
36 | if visable && page.HasFocus() {
37 | r.app.FocusChanged(page)
38 | }
39 | return r.Pages
40 | }
41 |
42 | // RemovePage is a wrapper for tview.Pages.RemovePage
43 | func (r *Pages) RemovePage(view tview.Identifier) *tview.Pages {
44 | r.Pages.RemovePage(string(view))
45 | r.app.GiveBackFocus()
46 | return r.Pages
47 | }
48 |
49 | // HasPage is a wrapper for tview.Pages.HasPage
50 | func (r *Pages) HasPage(view tview.Identifier) bool {
51 | return r.Pages.HasPage(string(view))
52 | }
53 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/kopecmaciej/vi-mongo
2 |
3 | go 1.22.1
4 |
5 | require (
6 | github.com/cosiner/argv v0.1.0
7 | github.com/gdamore/tcell/v2 v2.7.1
8 | github.com/kopecmaciej/tview v0.1.4
9 | github.com/rs/zerolog v1.32.0
10 | github.com/sashabaranov/go-openai v1.32.2
11 | github.com/spf13/pflag v1.0.5
12 | github.com/stretchr/testify v1.9.0
13 | go.mongodb.org/mongo-driver v1.14.0
14 | gopkg.in/yaml.v3 v3.0.1
15 | )
16 |
17 | require github.com/inconshreveable/mousetrap v1.1.0 // indirect
18 |
19 | require (
20 | github.com/adrg/xdg v0.4.0
21 | github.com/atotto/clipboard v0.1.4
22 | github.com/davecgh/go-spew v1.1.1 // indirect
23 | github.com/gdamore/encoding v1.0.0 // indirect
24 | github.com/golang/snappy v0.0.1 // indirect
25 | github.com/klauspost/compress v1.13.6 // indirect
26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
27 | github.com/mattn/go-colorable v0.1.13 // indirect
28 | github.com/mattn/go-isatty v0.0.19 // indirect
29 | github.com/mattn/go-runewidth v0.0.15 // indirect
30 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
31 | github.com/pmezard/go-difflib v1.0.0 // indirect
32 | github.com/rivo/uniseg v0.4.7 // indirect
33 | github.com/spf13/cobra v1.8.1
34 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect
35 | github.com/xdg-go/scram v1.1.2 // indirect
36 | github.com/xdg-go/stringprep v1.0.4 // indirect
37 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
38 | golang.org/x/crypto v0.17.0 // indirect
39 | golang.org/x/sync v0.1.0 // indirect
40 | golang.org/x/sys v0.17.0 // indirect
41 | golang.org/x/term v0.17.0 // indirect
42 | golang.org/x/text v0.14.0 // indirect
43 | )
44 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | project_name: vi-mongo
2 | version: 2
3 |
4 | before:
5 | hooks:
6 | - go mod tidy
7 | - go generate ./...
8 |
9 | builds:
10 | - env:
11 | - CGO_ENABLED=0
12 | goos:
13 | - linux
14 | - darwin
15 | - windows
16 | goarch:
17 | - amd64
18 | - arm64
19 | flags:
20 | - -trimpath
21 | ldflags:
22 | - -s -w
23 | - -X github.com/kopecmaciej/vi-mongo/cmd.version={{.Tag}}
24 |
25 | archives:
26 | - formats: ['tar.gz']
27 | name_template: >-
28 | {{ .ProjectName }}_
29 | {{- title .Os }}_
30 | {{- if eq .Arch "amd64" }}x86_64
31 | {{- else if eq .Arch "386" }}i386
32 | {{- else }}{{ .Arch }}{{ end }}
33 | {{- if .Arm }}v{{ .Arm }}{{ end }}
34 | format_overrides:
35 | - goos: windows
36 | formats: ['zip']
37 |
38 | changelog:
39 | sort: asc
40 | filters:
41 | exclude:
42 | - '^docs:'
43 | - '^test:'
44 | - '^ci:'
45 | groups:
46 | - title: '🎉 New Features'
47 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
48 | order: 0
49 | - title: '🐛 Bug Fixes'
50 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
51 | order: 1
52 | - title: '🔧 Maintenance'
53 | regexp: '^.*?(chore|refactor)(\([[:word:]]+\))??!?:.+$'
54 | order: 2
55 | - title: '💅 Styling'
56 | regexp: '^.*?style(\([[:word:]]+\))??!?:.+$'
57 | order: 3
58 | - title: '💡 Documentation'
59 | regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$'
60 | order: 4
61 |
62 | release:
63 | github:
64 | owner: kopecmaciej
65 | name: vi-mongo
66 |
67 | checksum:
68 | name_template: "{{ .ProjectName }}_checksum_sha256.txt"
69 |
--------------------------------------------------------------------------------
/internal/mongo/id.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/rs/zerolog/log"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | )
10 |
11 | // GetIDFromJSON returns the _id field of a JSON string as a primitive.ObjectID.
12 | func GetIDFromJSON(jsonString string) (any, error) {
13 | var doc map[string]any
14 | err := json.Unmarshal([]byte(jsonString), &doc)
15 | if err != nil {
16 | log.Error().Err(err).Msg("Error unmarshaling JSON")
17 | return nil, err
18 | }
19 |
20 | objectID, err := getIdFromDocument(doc)
21 | if err != nil {
22 | log.Error().Err(err).Msg("Error converting _id to ObjectID")
23 | return nil, err
24 | }
25 |
26 | return objectID, nil
27 | }
28 |
29 | // getIdFromDocument returns the _id field of a document as a primitive.ObjectID
30 | func getIdFromDocument(document map[string]any) (any, error) {
31 | rawId, ok := document["_id"]
32 | if !ok {
33 | return nil, fmt.Errorf("document has no _id")
34 | }
35 | var id any
36 | switch typedId := rawId.(type) {
37 | case primitive.ObjectID:
38 | return typedId, nil
39 | case string:
40 | id = typedId
41 | case map[string]any:
42 | oidString, ok := typedId["$oid"].(string)
43 | if !ok {
44 | return nil, fmt.Errorf("invalid $oid field in _id")
45 | }
46 | objectId, err := primitive.ObjectIDFromHex(oidString)
47 | if err != nil {
48 | return nil, fmt.Errorf("invalid ObjectID: %w", err)
49 | }
50 | id = objectId
51 | default:
52 | return nil, fmt.Errorf("document _id is not a string or primitive.ObjectID")
53 | }
54 |
55 | return id, nil
56 | }
57 |
58 | // StringifyId converts the _id field of a document to a string
59 | func StringifyId(id any) string {
60 | switch v := id.(type) {
61 | case primitive.ObjectID:
62 | return v.Hex()
63 | default:
64 | return fmt.Sprintf("%v", v)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/internal/util/json_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestIsJsonEmpty(t *testing.T) {
10 | testCases := []struct {
11 | name string
12 | input string
13 | expected bool
14 | }{
15 | {"Empty string", "", true},
16 | {"Whitespace only", " ", true},
17 | {"Empty object", "{}", true},
18 | {"Whitespace with empty object", " {} ", true},
19 | {"Non-empty object", `{"key": "value"}`, false},
20 | {"Non-empty array", "[1, 2, 3]", false},
21 | }
22 |
23 | for _, tc := range testCases {
24 | t.Run(tc.name, func(t *testing.T) {
25 | result := IsJsonEmpty(tc.input)
26 | assert.Equal(t, tc.expected, result)
27 | })
28 | }
29 | }
30 |
31 | func TestCleanJsonWhitespaces(t *testing.T) {
32 | testCases := []struct {
33 | name string
34 | input string
35 | expected string
36 | }{
37 | {
38 | name: "Remove newlines and extra spaces",
39 | input: "{\n\"key1\": \"value1\",\n \"key2\": \"value2\"\n}",
40 | expected: `{"key1": "value1", "key2": "value2"}`,
41 | },
42 | {
43 | name: "Remove trailing comma",
44 | input: `{"key": "value"},`,
45 | expected: `{"key": "value"}`,
46 | },
47 | {
48 | name: "Preserve spaces in quotes",
49 | input: `{"key 1": "value with spaces"}`,
50 | expected: `{"key 1": "value with spaces"}`,
51 | },
52 | {
53 | name: "Complex JSON",
54 | input: `{
55 | "key1": "value1",
56 | "key2": [1, 2, 3],
57 | "key3": {
58 | "nested": "object"
59 | },
60 | "key4": "value with \\"quotes\\""
61 | }`,
62 | expected: `{"key1": "value1","key2": [1, 2, 3],"key3": {"nested": "object"},"key4": "value with \\"quotes\\""}`,
63 | },
64 | }
65 |
66 | for _, tc := range testCases {
67 | t.Run(tc.name, func(t *testing.T) {
68 | result := CleanJsonWhitespaces(tc.input)
69 | assert.Equal(t, tc.expected, result)
70 | })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/release-notes/v0.1.32.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.32
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Regex Pattern Support**: Added comprehensive support for MongoDB regex patterns and literals in queries [#97](https://github.com/kopecmaciej/vi-mongo/issues/97)
10 | - Support for regex literal syntax (e.g., `/pattern/flags`)
11 | - Support for MongoDB helper functions: `ISODate()`, `NumberInt()`, `NumberLong()`, `NumberDecimal()`
12 | - Improved array handling in regex context
13 | - Added extensive utility functions for regex pattern processing
14 |
15 | ### Bug Fixes
16 |
17 | - **Copy Cell Value**: Fixed issue where copying a cell value in table view would copy the truncated display text instead of the full value
18 | - Now retrieves the actual document field value when copying
19 | - Example: Copying a cell showing { "object": "full_long_val..., now copies the full { "object": "full_long_value" }
20 |
21 | ### Improvements
22 |
23 | - **Error Handling**: Added panic recovery mechanism to display properly formatted error messages to users, improving the debugging experience and preventing abrupt application crashes
24 | - **Testing**: Added comprehensive test coverage for command handling and regex pattern processing
25 | - **Code Refactoring**: Improved state management by moving field value retrieval logic from UI components to state package
26 | - Added `GetValueByColumn` method to `CollectionState` for better separation of concerns
27 | - Reduced code duplication in content component (~30 lines removed)
28 |
29 | ### Technical Changes
30 |
31 | - **Code Organization**: Refactored regex handling into dedicated utility module (`internal/util/regexp.go`)
32 | - **Parser Enhancement**: Updated MongoDB query parser to handle regex patterns and MongoDB helper functions
33 |
34 | ## Thank You
35 |
36 | Thank you to everyone who helped improve this project!
37 |
--------------------------------------------------------------------------------
/internal/tui/modal/confirm.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/config"
7 | "github.com/kopecmaciej/vi-mongo/internal/manager"
8 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
9 | )
10 |
11 | type Confirm struct {
12 | *core.BaseElement
13 | *core.Modal
14 |
15 | confirmLabel string
16 | style *config.OthersStyle
17 | }
18 |
19 | func NewConfirm(id tview.Identifier) *Confirm {
20 | dm := &Confirm{
21 | BaseElement: core.NewBaseElement(),
22 | Modal: core.NewModal(),
23 | confirmLabel: "Confirm",
24 | }
25 |
26 | dm.SetIdentifier(id)
27 | dm.SetAfterInitFunc(dm.init)
28 |
29 | return dm
30 | }
31 |
32 | func (c *Confirm) init() error {
33 | c.setLayout()
34 | c.setStyle()
35 | c.setKeybindings()
36 |
37 | c.handleEvents()
38 |
39 | return nil
40 | }
41 |
42 | func (c *Confirm) setLayout() {
43 | c.AddButtons([]string{c.confirmLabel, "Cancel"})
44 | c.SetBorder(true)
45 | c.SetTitle(" " + c.confirmLabel + " ")
46 | c.SetBorderPadding(0, 0, 1, 1)
47 | }
48 |
49 | func (c *Confirm) setStyle() {
50 | c.SetStyle(c.App.GetStyles())
51 | c.style = &c.App.GetStyles().Others
52 |
53 | c.SetButtonActivatedStyle(tcell.StyleDefault.
54 | Background(c.style.DeleteButtonSelectedBackgroundColor.Color()))
55 | }
56 |
57 | func (c *Confirm) setKeybindings() {
58 | c.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
59 | switch event.Rune() {
60 | case 'h':
61 | return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
62 | case 'l':
63 | return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
64 | }
65 | return event
66 | })
67 | }
68 |
69 | func (c *Confirm) handleEvents() {
70 | go c.HandleEvents(c.GetIdentifier(), func(event manager.EventMsg) {
71 | switch event.Message.Type {
72 | case manager.StyleChanged:
73 | c.setStyle()
74 | }
75 | })
76 | }
77 |
78 | func (c *Confirm) SetConfirmButtonLabel(label string) {
79 | c.confirmLabel = label
80 | c.ClearButtons()
81 | c.AddButtons([]string{label, "Cancel"})
82 | }
83 |
--------------------------------------------------------------------------------
/internal/mongo/id_test.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | "go.mongodb.org/mongo-driver/bson/primitive"
8 | )
9 |
10 | func TestGetIDFromJSON(t *testing.T) {
11 | t.Run("Valid JSON with ObjectID", func(t *testing.T) {
12 | jsonString := `{"_id": {"$oid": "5f8f9e5f1c9d440000d1b3c5"}}`
13 | id, err := GetIDFromJSON(jsonString)
14 | assert.NoError(t, err)
15 | assert.IsType(t, primitive.ObjectID{}, id)
16 | assert.Equal(t, "5f8f9e5f1c9d440000d1b3c5", id.(primitive.ObjectID).Hex())
17 | })
18 |
19 | t.Run("Invalid JSON", func(t *testing.T) {
20 | jsonString := `{"_id": "invalid`
21 | _, err := GetIDFromJSON(jsonString)
22 | assert.Error(t, err)
23 | })
24 | }
25 |
26 | func TestGetIDFromDocument(t *testing.T) {
27 | t.Run("Document with ObjectID", func(t *testing.T) {
28 | objectID := primitive.NewObjectID()
29 | doc := map[string]any{"_id": objectID}
30 | id, err := getIdFromDocument(doc)
31 | assert.NoError(t, err)
32 | assert.Equal(t, objectID, id)
33 | })
34 |
35 | t.Run("Document with string ID", func(t *testing.T) {
36 | doc := map[string]any{"_id": "123456"}
37 | id, err := getIdFromDocument(doc)
38 | assert.NoError(t, err)
39 | assert.Equal(t, "123456", id)
40 | })
41 |
42 | t.Run("Document with $oid", func(t *testing.T) {
43 | doc := map[string]any{"_id": map[string]any{"$oid": "5f8f9e5f1c9d440000d1b3c5"}}
44 | id, err := getIdFromDocument(doc)
45 | assert.NoError(t, err)
46 | assert.IsType(t, primitive.ObjectID{}, id)
47 | assert.Equal(t, "5f8f9e5f1c9d440000d1b3c5", id.(primitive.ObjectID).Hex())
48 | })
49 |
50 | t.Run("Document without _id", func(t *testing.T) {
51 | doc := map[string]any{"name": "John"}
52 | _, err := getIdFromDocument(doc)
53 | assert.Error(t, err)
54 | assert.Contains(t, err.Error(), "document has no _id")
55 | })
56 | }
57 |
58 | func TestStringifyId(t *testing.T) {
59 | t.Run("ObjectID", func(t *testing.T) {
60 | objectID := primitive.NewObjectID()
61 | result := StringifyId(objectID)
62 | assert.Equal(t, objectID.Hex(), result)
63 | })
64 |
65 | t.Run("String", func(t *testing.T) {
66 | result := StringifyId("123456")
67 | assert.Equal(t, "123456", result)
68 | })
69 |
70 | t.Run("Int", func(t *testing.T) {
71 | result := StringifyId(123456)
72 | assert.Equal(t, "123456", result)
73 | })
74 | }
75 |
--------------------------------------------------------------------------------
/internal/manager/element.go:
--------------------------------------------------------------------------------
1 | package manager
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/gdamore/tcell/v2"
7 | "github.com/kopecmaciej/tview"
8 | )
9 |
10 | const (
11 | FocusChanged MessageType = "focus_changed"
12 | StyleChanged MessageType = "style_changed"
13 | UpdateAutocompleteKeys MessageType = "update_autocomplete"
14 | UpdateQueryBar MessageType = "update_query_bar"
15 | )
16 |
17 | type (
18 | MessageType string
19 | // Messages is a list of messages that can be sent to the manager
20 | Message struct {
21 | Type MessageType
22 | Data any
23 | }
24 |
25 | // EventMsg is a wrapper for tcell.EventKey that also contains
26 | // the sender of the event
27 | EventMsg struct {
28 | *tcell.EventKey
29 | Sender tview.Identifier
30 | Message Message
31 | }
32 |
33 | // ElementManager is a helper to manage different Elements
34 | // and their key handlers, so that only the key handlers of the
35 | // current element are executed
36 | ElementManager struct {
37 | mutex sync.Mutex
38 | listeners map[tview.Identifier]chan EventMsg
39 | }
40 | )
41 |
42 | // NewElementManager creates a new ElementManager
43 | func NewElementManager() *ElementManager {
44 | return &ElementManager{
45 | mutex: sync.Mutex{},
46 | listeners: make(map[tview.Identifier]chan EventMsg),
47 | }
48 | }
49 |
50 | // Subscribe subscribes to events from a specific element
51 | func (eh *ElementManager) Subscribe(element tview.Identifier) chan EventMsg {
52 | eh.mutex.Lock()
53 | defer eh.mutex.Unlock()
54 | listener := make(chan EventMsg, 1)
55 | eh.listeners[element] = listener
56 | return listener
57 | }
58 |
59 | // Unsubscribe unsubscribes from events from a specific element
60 | func (eh *ElementManager) Unsubscribe(element tview.Identifier, listener chan EventMsg) {
61 | eh.mutex.Lock()
62 | defer eh.mutex.Unlock()
63 | delete(eh.listeners, element)
64 | }
65 |
66 | // Broadcast sends an event to all listeners
67 | func (eh *ElementManager) Broadcast(event EventMsg) {
68 | eh.mutex.Lock()
69 | defer eh.mutex.Unlock()
70 | for _, listener := range eh.listeners {
71 | listener <- event
72 | }
73 | }
74 |
75 | // SendTo sends an event to a specific element
76 | func (eh *ElementManager) SendTo(element tview.Identifier, event EventMsg) {
77 | eh.mutex.Lock()
78 | defer eh.mutex.Unlock()
79 | if listener, exists := eh.listeners[element]; exists {
80 | listener <- event
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/release-notes/v0.1.28.md:
--------------------------------------------------------------------------------
1 | # Release v0.1.28
2 |
3 |
4 |
5 | ## What's New
6 |
7 | ### Features
8 |
9 | - **Multiple Document Selection**
10 | - Added support for selecting and deleting multiple documents at once
11 | - Use `V` to enter selection mode and select documents
12 | - Press `Esc` to clear selection
13 | - Delete multiple selected documents with a single action
14 | - **Selection Priority**: When documents are selected, delete operations will only affect selected documents. Delete operations on the document under cursor only work when nothing is selected
15 | - Note: Multi-selection is not available in JSON view mode
16 | - New keybindings for multiple selection/clear selection
17 |
18 | ```json
19 | "multipleSelect": {
20 | "runes": [
21 | "V"
22 | ],
23 | "description": "Multiple select"
24 | },
25 | "clearSelection": {
26 | "keys": [
27 | "Esc"
28 | ],
29 | "description": "Clear selection"
30 | }
31 |
32 | ```
33 |
34 | - **Enhanced Security**
35 | - Added optional password encryption for MongoDB connection credentials
36 | - Passwords can now be encrypted and stored securely in configuration files
37 | - Support for encryption keys via environment variables or key files
38 | - Automatic encryption/decryption when adding and using connections
39 |
40 | **⚠️ Important Notice**: Password encryption is currently optional, but will become the standard method for handling passwords in future releases. This wasn't made mandatory in this version to allow time for proper app preparation and ensure a smooth transition for all users.
41 |
42 | For detailed setup instructions, see the [Security Documentation](https://github.com/kopecmaciej/vi-mongo/blob/master/docs/security.md)
43 |
44 | ### Bug Fixes
45 |
46 | - Fixed connection configuration: changed `url` field to `uri` in connection page for consistency (not yet changed in config.yaml)
47 | - Reduced log spam by fixing MongoDB collection index stats authorization warnings (now shows single warning instead of multiple messages)
48 | - Fixed CLI message printing issues when using command-line interface
49 | - Improved overall logging behavior and message handling
50 |
51 | ## Thank You
52 |
53 | Thank you to everyone who helped improve this project!
54 |
--------------------------------------------------------------------------------
/internal/config/styles/default.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | backgroundColor: "#0F172A"
3 | contrastBackgroundColor: "#1E293B"
4 | moreContrastBackgroundColor: "#387D44"
5 | textColor: "#E2E8F0"
6 | secondaryTextColor: "#FDE68A"
7 | borderColor: "#387D44"
8 | focusColor: "#4ADE80"
9 | titleColor: "#387D44"
10 | graphicsColor: "#387D44"
11 | welcome:
12 | formLabelColor: "#FDE68A"
13 | formInputColor: "#E2E8F0"
14 | formInputBackgroundColor: "#1E293B"
15 | connection:
16 | formLabelColor: "#FDE68A"
17 | formInputColor: "#E2E8F0"
18 | formInputBackgroundColor: "#1E293B"
19 | listTextColor: "#F1FA8C"
20 | listSelectedTextColor: "#F1FA8C"
21 | listSelectedBackgroundColor: "#387D44"
22 | listSecondaryTextColor: "#387D44"
23 | listSecondaryBackgroundColor: "#0F172A"
24 | header:
25 | keyColor: "#FDE68A"
26 | valueColor: "#387D44"
27 | activeSymbol: ●
28 | inactiveSymbol: ○
29 | tabBar:
30 | activeTextColor: "#FDE68A"
31 | activeBackgroundColor: "#387D44"
32 | databases:
33 | nodeTextColor: "#387D44"
34 | leafTextColor: "#E2E8F0"
35 | nodeSymbolColor: "#FDE68A"
36 | leafSymbolColor: "#387D44"
37 | openNodeSymbol: " "
38 | closedNodeSymbol: " "
39 | leafSymbol: " "
40 | content:
41 | statusTextColor: "#FDE68A"
42 | headerRowColor: "#1E293B"
43 | columnKeyColor: "#FDE68A"
44 | columnTypeColor: "#387D44"
45 | cellTextColor: "#387D44"
46 | selectedRowColor: "#4ADE80"
47 | multiSelectedRowColor: "#2E6B4A"
48 | docPeeker:
49 | keyColor: "#387D44"
50 | valueColor: "#E2E8F0"
51 | bracketColor: "#FDE68A"
52 | highlightColor: "#3A4963"
53 | inputBar:
54 | labelColor: "#FDE68A"
55 | inputColor: "#E2E8F0"
56 | autocomplete:
57 | backgroundColor: "#1E293B"
58 | textColor: "#E2E8F0"
59 | activeBackgroundColor: "#387D44"
60 | activeTextColor: "#0F172A"
61 | secondaryTextColor: "#FDE68A"
62 | history:
63 | textColor: "#E2E8F0"
64 | selectedTextColor: "#0F172A"
65 | selectedBackgroundColor: "#387D44"
66 | help:
67 | headerColor: "#387D44"
68 | keyColor: "#FDE68A"
69 | descriptionColor: "#E2E8F0"
70 | others:
71 | buttonsTextColor: "#FDE68A"
72 | buttonsBackgroundColor: "#387D44"
73 | deleteButtonSelectedBackgroundColor: "#DA3312"
74 | modalTextColor: "#FDE68A"
75 | modalSecondaryTextColor: "#387D44"
76 | separatorSymbol: "|"
77 | separatorColor: "#334155"
78 | styleChange:
79 | textColor: "#E2E8F0"
80 | selectedTextColor: "#0F172A"
81 | selectedBackgroundColor: "#387D44"
82 | aiPrompt:
83 | formLabelColor: "#FDE68A"
84 | formInputColor: "#E2E8F0"
85 | formInputBackgroundColor: "#1E293B"
86 |
--------------------------------------------------------------------------------
/internal/config/styles/dracula.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | backgroundColor: "#282A36"
3 | contrastBackgroundColor: "#44475A"
4 | moreContrastBackgroundColor: "#BD93F9"
5 | textColor: "#F8F8F2"
6 | secondaryTextColor: "#FFB86C"
7 | borderColor: "#BD93F9"
8 | focusColor: "#FF79C6"
9 | titleColor: "#FF79C6"
10 | graphicsColor: "#BD93F9"
11 | welcome:
12 | formLabelColor: "#FFB86C"
13 | formInputColor: "#F8F8F2"
14 | formInputBackgroundColor: "#44475A"
15 | connection:
16 | formLabelColor: "#FFB86C"
17 | formInputColor: "#F8F8F2"
18 | formInputBackgroundColor: "#44475A"
19 | listTextColor: "#F8F8F2"
20 | listSelectedTextColor: "#FFFFFF"
21 | listSelectedBackgroundColor: "#BD93F9"
22 | listSecondaryTextColor: "#BD93F9"
23 | listSecondaryBackgroundColor: "#282A36"
24 | header:
25 | keyColor: "#FFB86C"
26 | valueColor: "#BD93F9"
27 | activeSymbol: ●
28 | inactiveSymbol: ○
29 | tabBar:
30 | activeTextColor: "#FFFFFF"
31 | activeBackgroundColor: "#BD93F9"
32 | databases:
33 | nodeTextColor: "#BD93F9"
34 | leafTextColor: "#F8F8F2"
35 | nodeSymbolColor: "#FFB86C"
36 | leafSymbolColor: "#BD93F9"
37 | openNodeSymbol: " "
38 | closedNodeSymbol: " "
39 | leafSymbol: " "
40 | content:
41 | statusTextColor: "#FFB86C"
42 | headerRowColor: "#44475A"
43 | columnKeyColor: "#FFB86C"
44 | columnTypeColor: "#BD93F9"
45 | cellTextColor: "#F8F8F2"
46 | selectedRowColor: "#50FA7B"
47 | multiSelectedRowColor: "#6D7B9D"
48 | docPeeker:
49 | keyColor: "#BD93F9"
50 | valueColor: "#F8F8F2"
51 | bracketColor: "#FFB86C"
52 | highlightColor: "#44475A"
53 |
54 | inputBar:
55 | labelColor: "#FFB86C"
56 | inputColor: "#F8F8F2"
57 | autocomplete:
58 | backgroundColor: "#44475A"
59 | textColor: "#F8F8F2"
60 | activeBackgroundColor: "#BD93F9"
61 | activeTextColor: "#282A36"
62 | secondaryTextColor: "#FFB86C"
63 | history:
64 | textColor: "#F8F8F2"
65 | selectedTextColor: "#FFFFFF"
66 | selectedBackgroundColor: "#BD93F9"
67 | help:
68 | headerColor: "#FF79C6"
69 | keyColor: "#FFB86C"
70 | descriptionColor: "#F8F8F2"
71 | others:
72 | buttonsTextColor: "#FFFFFF"
73 | buttonsBackgroundColor: "#BD93F9"
74 | deleteButtonSelectedBackgroundColor: "#FF5555"
75 | modalTextColor: "#FFB86C"
76 | modalSecondaryTextColor: "#BD93F9"
77 | separatorSymbol: "|"
78 | separatorColor: "#44475A"
79 | styleChange:
80 | textColor: "#F8F8F2"
81 | selectedTextColor: "#FFFFFF"
82 | selectedBackgroundColor: "#BD93F9"
83 | aiPrompt:
84 | formLabelColor: "#FFB86C"
85 | formInputColor: "#F8F8F2"
86 | formInputBackgroundColor: "#44475A"
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | ---
6 |
7 | ## Overview
8 |
9 | **Vi Mongo** is an intuitive Terminal User Interface (TUI) application, written
10 | in Go, designed to streamline and simplify the management of MongoDB databases.
11 | Emphasizing ease of use without sacrificing functionality, Vi Mongo offers a
12 | user-friendly command-line experience for database administrators and developers
13 | alike.
14 |
15 | Visit [vi-mongo.com](https://vi-mongo.com) for more information.
16 |
17 | 
18 |
19 | ## Installation
20 |
21 | To install Vi Mongo, follow the instructions on the [installation page](https://vi-mongo.com/docs/installation).
22 |
23 | If you are using [Neovim](https://neovim.io/) you can install the plugin from [nvim-plugin](https://github.com/kopecmaciej/vi-mongo.nvim)
24 |
25 | ## Features
26 |
27 | - **Intuitive Navigation**: Vi Mongo's simple, intuitive interface makes it easy
28 | to navigate and manage your MongoDB databases.
29 | - **Fast switching between databases**: Vi Mongo allows you to fast switch
30 | between databases.
31 | - **Managing Documents**: Vi Mongo allows you to view, create, update, duplicate
32 | and delete documents in your databases with ease.
33 | - **Managing Collections**: Vi Mongo provides a simple way to manage your
34 | collections, including the ability to create, delete collections.
35 | - **Autocomplete**: Vi Mongo offers an autocomplete feature that suggests
36 | collection names, database names, and MongoDB commands as you type.
37 | - **Query History**: Vi Mongo keeps track of your query history, allowing you to
38 | easily access and reuse previous queries.
39 | - **Mongosh Syntax Support**: Vi Mongo supports standard MongoDB Shell
40 | (mongosh) syntax, including regex literals (`/pattern/flags`), `ISODate()`,
41 | `NumberInt()`, `NumberLong()`, and `NumberDecimal()` helper functions.
42 | - **Multiple Styles**: Vi Mongo supports multiple color schemes, they can be
43 | selected in config file or add/modify easily.
44 |
45 | ## List of features to be implemented
46 |
47 | [vi-mongo.com/docs/roadmap](https://vi-mongo.com/docs/roadmap)
48 |
49 | ## Contributing
50 |
51 | All contributions are welcome!
52 |
53 | 1. Create an issue
54 | 2. Fork the repository
55 | 3. Go with the flow
56 |
57 | If possible please write tests for your changes.
58 |
59 | ## Issues
60 |
61 | For now all issues are resolved, but if you find any new issues, please report
62 | them in the [GitHub Issues](https://github.com/kopecmaciej/vi-mongo/issues)
63 | page.
64 |
--------------------------------------------------------------------------------
/internal/config/styles/dark-blue.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | backgroundColor: "#1E1E2E"
3 | contrastBackgroundColor: "#3D3D4D"
4 | moreContrastBackgroundColor: "#3D3D4D"
5 | textColor: "#E0E0E0"
6 | secondaryTextColor: "#A0A0B0"
7 | borderColor: "#3D3D4D"
8 | focusColor: "#FF9580"
9 | titleColor: "#61AFEF"
10 | graphicsColor: "#61AFEF"
11 | welcome:
12 | formLabelColor: "#61AFEF"
13 | formInputColor: "#E0E0E0"
14 | formInputBackgroundColor: "#2A2A3A"
15 | connection:
16 | formLabelColor: "#FF9580"
17 | formInputBackgroundColor: "#2A2A3A"
18 | formInputColor: "#E0E0E0"
19 | listTextColor: "#61AFEF"
20 | listSelectedTextColor: "#1E1E2E"
21 | listSelectedBackgroundColor: "#61AFEF"
22 | listSecondaryTextColor: "#A0A0B0"
23 | listSecondaryBackgroundColor: "#1E1E2E"
24 | header:
25 | keyColor: "#FF9580"
26 | valueColor: "#61AFEF"
27 | activeSymbol: ●
28 | inactiveSymbol: ○
29 | tabBar:
30 | activeTextColor: "#FF9580"
31 | activeBackgroundColor: "#61AFEF"
32 | databases:
33 | nodeTextColor: "#61AFEF"
34 | leafTextColor: "#E0E0E0"
35 | nodeSymbolColor: "#FF8700"
36 | leafSymbolColor: "#00A86B"
37 | openNodeSymbol: " "
38 | closedNodeSymbol: " "
39 | leafSymbol: " "
40 | content:
41 | statusTextColor: "#A0A0B0"
42 | headerRowColor: "#2A2A3A"
43 | columnKeyColor: "#FF9580"
44 | columnTypeColor: "#00A86B"
45 | cellTextColor: "#E0E0E0"
46 | activeRowColor: "#61AFEF"
47 | selectedRowColor: "#61AFEF"
48 | multiSelectedRowColor: "#4D5B7D"
49 | docPeeker:
50 | keyColor: "#FF9580"
51 | valueColor: "#E0E0E0"
52 | bracketColor: "#FF6B8B"
53 | highlightColor: "#2A2A3A"
54 | inputBar:
55 | labelColor: "#FF9580"
56 | inputColor: "#E0E0E0"
57 | autocomplete:
58 | backgroundColor: "#2A2A3A"
59 | textColor: "#E0E0E0"
60 | activeBackgroundColor: "#61AFEF"
61 | activeTextColor: "#1E1E2E"
62 | secondaryTextColor: "#A0A0B0"
63 | history:
64 | textColor: "#E0E0E0"
65 | selectedTextColor: "#1E1E2E"
66 | selectedBackgroundColor: "#61AFEF"
67 | help:
68 | headerColor: "#61AFEF"
69 | keyColor: "#FF9580"
70 | descriptionColor: "#E0E0E0"
71 | others:
72 | buttonsTextColor: "#E0E0E0"
73 | buttonsBackgroundColor: "#61AFEF"
74 | deleteButtonTextColor: "#E0E0E0"
75 | deleteButtonBackgroundColor: "#DA3312"
76 | modalTextColor: "#E0E0E0"
77 | modalSecondaryTextColor: "#61AFEF"
78 | separatorSymbol: "|"
79 | separatorColor: "#3D3D4D"
80 | styleChange:
81 | textColor: "#E0E0E0"
82 | selectedTextColor: "#1E1E2E"
83 | selectedBackgroundColor: "#61AFEF"
84 | aiPrompt:
85 | formLabelColor: "#61AFEF"
86 | formInputColor: "#E0E0E0"
87 | formInputBackgroundColor: "#2A2A3A"
88 |
--------------------------------------------------------------------------------
/internal/config/styles/light.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | backgroundColor: "#FFFFFF"
3 | contrastBackgroundColor: "#D0E8CF"
4 | moreContrastBackgroundColor: "#B7D0B6"
5 | textColor: "#2A2A3F"
6 | secondaryTextColor: "#4A5B8C"
7 | borderColor: "#B0B2C0"
8 | focusColor: "#FF9580"
9 | titleColor: "#0184BC"
10 | graphicsColor: "#0184BC"
11 | welcome:
12 | formLabelColor: "#FF9580"
13 | formInputColor: "#2A2A3F"
14 | formInputBackgroundColor: "#ECEDF2"
15 | connection:
16 | formLabelColor: "#FF9580"
17 | formInputBackgroundColor: "#ECEDF2"
18 | formInputColor: "#2A2A3F"
19 | formButtonColor: "#0184BC"
20 | listTextColor: "#2A2A3F"
21 | listSelectedTextColor: "#FFFFFF"
22 | listSelectedBackgroundColor: "#0184BC"
23 | listSecondaryTextColor: "#4A5B8C"
24 | listSecondaryBackgroundColor: "#FFFFFF"
25 | header:
26 | keyColor: "#FF9580"
27 | valueColor: "#0184BC"
28 | activeSymbol: ●
29 | inactiveSymbol: ○
30 | tabBar:
31 | activeTextColor: "#FF9580"
32 | activeBackgroundColor: "#0184BC"
33 | databases:
34 | nodeTextColor: "#0184BC"
35 | leafTextColor: "#2A2A3F"
36 | nodeSymbolColor: "#FF8700"
37 | leafSymbolColor: "#00A86B"
38 | openNodeSymbol: " "
39 | closedNodeSymbol: " "
40 | leafSymbol: " "
41 | content:
42 | statusTextColor: "#4A5B8C"
43 | headerRowColor: "#C8CAD5"
44 | columnKeyColor: "#FF9580"
45 | columnTypeColor: "#00A86B"
46 | cellTextColor: "#2A2A3F"
47 | activeRowColor: "#0184BC"
48 | selectedRowColor: "#0184BC"
49 | multiSelectedRowColor: "#4A6B9C"
50 | docPeeker:
51 | keyColor: "#FF9580"
52 | valueColor: "#2A2A3F"
53 | bracketColor: "#FF6B8B"
54 | highlightColor: "#e2e2e2"
55 | inputBar:
56 | labelColor: "#FF9580"
57 | inputColor: "#2A2A3F"
58 | autocomplete:
59 | backgroundColor: "#ECEDF2"
60 | textColor: "#2A2A3F"
61 | activeBackgroundColor: "#0184BC"
62 | activeTextColor: "#FFFFFF"
63 | secondaryTextColor: "#4A5B8C"
64 | history:
65 | textColor: "#2A2A3F"
66 | selectedTextColor: "#FFFFFF"
67 | selectedBackgroundColor: "#0184BC"
68 | help:
69 | headerColor: "#0184BC"
70 | keyColor: "#FF9580"
71 | descriptionColor: "#2A2A3F"
72 | others:
73 | buttonsTextColor: "#2A2A3F"
74 | buttonsBackgroundColor: "#0184BC"
75 | deleteButtonSelectedBackgroundColor: "#DA3312"
76 | modalTextColor: "#2A2A3F"
77 | modalSecondaryTextColor: "#0184BC"
78 | separatorSymbol: "|"
79 | separatorColor: "#B0B2C0"
80 | styleChange:
81 | textColor: "#2A2A3F"
82 | selectedTextColor: "#FFFFFF"
83 | selectedBackgroundColor: "#0184BC"
84 | aiPrompt:
85 | formLabelColor: "#FF9580"
86 | formInputColor: "#2A2A3F"
87 | formInputBackgroundColor: "#ECEDF2"
88 |
--------------------------------------------------------------------------------
/internal/config/styles/light-forest.yaml:
--------------------------------------------------------------------------------
1 | global:
2 | backgroundColor: "#F0F4E8"
3 | contrastBackgroundColor: "#D0E8CF"
4 | moreContrastBackgroundColor: "#B7D0B6"
5 | textColor: "#2C3E2D"
6 | secondaryTextColor: "#5B8C5A"
7 | borderColor: "#B7D0B6"
8 | focusColor: "#FF8700"
9 | titleColor: "#2E7D32"
10 | graphicsColor: "#2E7D32"
11 | welcome:
12 | formLabelColor: "#FF9580"
13 | formInputColor: "#2C3E2D"
14 | formInputBackgroundColor: "#FFFFFF"
15 | connection:
16 | formLabelColor: "#FF9580"
17 | formInputBackgroundColor: "#FFFFFF"
18 | formInputColor: "#2C3E2D"
19 | formButtonColor: "#2E7D32"
20 | listTextColor: "#2E7D32"
21 | listSelectedTextColor: "#FFFFFF"
22 | listSelectedBackgroundColor: "#2E7D32"
23 | listSecondaryTextColor: "#FF9580"
24 | listSecondaryBackgroundColor: "#F0F4E8"
25 | header:
26 | keyColor: "#FF9580"
27 | valueColor: "#2E7D32"
28 | activeSymbol: ●
29 | inactiveSymbol: ○
30 | tabBar:
31 | activeTextColor: "#FF9580"
32 | activeBackgroundColor: "#2E7D32"
33 | databases:
34 | nodeTextColor: "#2E7D32"
35 | leafTextColor: "#2C3E2D"
36 | nodeSymbolColor: "#FF8700"
37 | leafSymbolColor: "#00A86B"
38 | openNodeSymbol: " "
39 | closedNodeSymbol: " "
40 | leafSymbol: " "
41 | content:
42 | statusTextColor: "#5B8C5A"
43 | headerRowColor: "#D0E8CF"
44 | columnKeyColor: "#FF9580"
45 | columnTypeColor: "#00A86B"
46 | cellTextColor: "#2C3E2D"
47 | activeRowColor: "#2E7D32"
48 | selectedRowColor: "#2E7D32"
49 | multiSelectedRowColor: "#4A8C5A"
50 | docPeeker:
51 | keyColor: "#FF9580"
52 | valueColor: "#2C3E2D"
53 | bracketColor: "#FF6B8B"
54 | highlightColor: "#D0E8CF"
55 | inputBar:
56 | labelColor: "#FF9580"
57 | inputColor: "#2C3E2D"
58 | autocomplete:
59 | backgroundColor: "#FFFFFF"
60 | textColor: "#2C3E2D"
61 | activeBackgroundColor: "#2E7D32"
62 | activeTextColor: "#FFFFFF"
63 | secondaryTextColor: "#5B8C5A"
64 | history:
65 | textColor: "#2C3E2D"
66 | selectedTextColor: "#FFFFFF"
67 | selectedBackgroundColor: "#2E7D32"
68 | help:
69 | headerColor: "#2E7D32"
70 | keyColor: "#FF9580"
71 | descriptionColor: "#2C3E2D"
72 | others:
73 | buttonsTextColor: "#FFFFFF"
74 | buttonsBackgroundColor: "#2E7D32"
75 | deleteButtonSelectedBackgroundColor: "#DA3312"
76 | modalTextColor: "#2C3E2D"
77 | modalSecondaryTextColor: "#2E7D32"
78 | separatorSymbol: "|"
79 | separatorColor: "#B7D0B6"
80 | styleChange:
81 | textColor: "#2C3E2D"
82 | selectedTextColor: "#FFFFFF"
83 | selectedBackgroundColor: "#2E7D32"
84 | aiPrompt:
85 | formLabelColor: "#FF9580"
86 | formInputColor: "#2C3E2D"
87 | formInputBackgroundColor: "#FFFFFF"
88 |
--------------------------------------------------------------------------------
/internal/tui/modal/server_info.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/kopecmaciej/tview"
8 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
9 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/primitives"
11 | )
12 |
13 | const ServerInfoModalId = "ServerInfoModal"
14 |
15 | type ServerInfoModal struct {
16 | *core.BaseElement
17 | *primitives.ViewModal
18 |
19 | dao *mongo.Dao
20 | }
21 |
22 | func NewServerInfoModal(dao *mongo.Dao) *ServerInfoModal {
23 | s := &ServerInfoModal{
24 | BaseElement: core.NewBaseElement(),
25 | ViewModal: primitives.NewViewModal(),
26 | dao: dao,
27 | }
28 |
29 | s.SetIdentifier(ServerInfoModalId)
30 | s.SetTitle("Server Info")
31 | return s
32 | }
33 |
34 | func (s *ServerInfoModal) Init(app *core.App) {
35 | s.App = app
36 | s.setStyle()
37 | }
38 |
39 | func (s *ServerInfoModal) setStyle() {
40 | s.ViewModal.SetBackgroundColor(s.App.GetStyles().Global.BackgroundColor.Color())
41 | s.ViewModal.SetTextColor(s.App.GetStyles().Global.TextColor.Color())
42 | s.ViewModal.SetButtonBackgroundColor(s.App.GetStyles().Global.BackgroundColor.Color())
43 | s.ViewModal.SetButtonTextColor(s.App.GetStyles().Global.TextColor.Color())
44 | }
45 |
46 | func (s *ServerInfoModal) Render(ctx context.Context) error {
47 | ss, err := s.dao.GetServerStatus(ctx)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | info := map[string]string{
53 | "Host": s.dao.Config.Host,
54 | "Port": fmt.Sprintf("%d", s.dao.Config.Port),
55 | "Database": s.dao.Config.Database,
56 | "Version": ss.Version,
57 | "Uptime": fmt.Sprintf("%d seconds", ss.Uptime),
58 | "Current Connections": fmt.Sprintf("%d", ss.CurrentConns),
59 | "Available Connections": fmt.Sprintf("%d", ss.AvailableConns),
60 | "Resident Memory": fmt.Sprintf("%d MB", ss.Mem.Resident),
61 | "Virtual Memory": fmt.Sprintf("%d MB", ss.Mem.Virtual),
62 | "Is Master": fmt.Sprintf("%v", ss.Repl.IsMaster),
63 | }
64 |
65 | content := ""
66 | for key, value := range info {
67 | content += fmt.Sprintf("[%s]%s[%s] %s\n", s.App.GetStyles().Others.ModalTextColor.Color(), key, s.App.GetStyles().Others.ModalSecondaryTextColor.Color(), value)
68 | }
69 |
70 | s.ViewModal.SetText(primitives.Text{
71 | Content: content,
72 | Align: tview.AlignLeft,
73 | })
74 | s.ViewModal.AddButtons([]string{"Close"})
75 | s.ViewModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
76 | s.App.Pages.RemovePage(ServerInfoModalId)
77 | })
78 |
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/internal/util/mongo_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/stretchr/testify/assert"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | func TestGetSortedKeysWithTypes(t *testing.T) {
13 | documents := []primitive.M{
14 | {"name": "John", "age": 30, "active": true},
15 | {"name": "Jane", "age": 25.5, "email": "jane@example.com"},
16 | }
17 |
18 | result := GetSortedKeysWithTypes(documents, tcell.ColorBlue.Name())
19 |
20 | expected := []string{
21 | "active [blue]Bool",
22 | "age [blue]Mixed",
23 | "email [blue]String",
24 | "name [blue]String",
25 | }
26 |
27 | assert.Equal(t, expected, result)
28 | }
29 |
30 | func TestGetValueByType(t *testing.T) {
31 | objId := primitive.NewObjectID()
32 | dateUtc := "2023-10-05T14:34:24Z"
33 | fixedDate := time.Date(2023, 10, 5, 14, 34, 24, 0, time.UTC)
34 | date := primitive.NewDateTimeFromTime(fixedDate)
35 |
36 | testCases := []struct {
37 | name string
38 | input any
39 | expected string
40 | }{
41 | {"String", "test", "test"},
42 | {"Int32", int32(56), "56"},
43 | {"Int64", int64(922337203685477), "922337203685477"},
44 | {"Float", 3.14, "3.14"},
45 | {"Bool", true, "true"},
46 | {"ObjectID", objId, objId.Hex()},
47 | {"DateTime", date, dateUtc},
48 | // Arrays use tview.Escape() so closing brackets become [] to prevent color tag interpretation
49 | {"Array", primitive.A{"a", "b"}, `["a","b"[]`},
50 | {"Single item array", primitive.A{"single"}, `["single"[]`},
51 | {"Object", primitive.M{"key": "value"}, `{"key":"value"}`},
52 | {"Null", nil, "null"},
53 | }
54 |
55 | for _, tc := range testCases {
56 | t.Run(tc.name, func(t *testing.T) {
57 | result := StringifyMongoValueByType(tc.input)
58 | assert.Equal(t, tc.expected, result)
59 | })
60 | }
61 | }
62 |
63 | func TestGetMongoType(t *testing.T) {
64 | testCases := []struct {
65 | name string
66 | input any
67 | expected string
68 | }{
69 | {"String", "test", TypeString},
70 | {"Int32", int32(56), TypeInt32},
71 | {"Int64", int64(922337203685477), TypeInt64},
72 | {"Float32", float32(3.14), TypeDouble},
73 | {"Float64", float64(3.14), TypeDouble},
74 | {"Bool", true, TypeBool},
75 | {"ObjectID", primitive.NewObjectID(), TypeObjectId},
76 | {"DateTime", primitive.NewDateTimeFromTime(time.Now()), TypeDate},
77 | {"Array", primitive.A{"a", "b"}, TypeArray},
78 | {"Object", primitive.M{"key": "value"}, TypeObject},
79 | {"Null", nil, TypeNull},
80 | }
81 |
82 | for _, tc := range testCases {
83 | t.Run(tc.name, func(t *testing.T) {
84 | result := GetMongoType(tc.input)
85 | assert.Equal(t, tc.expected, result)
86 | })
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/internal/tui/core/table.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/config"
7 | )
8 |
9 | type Table struct {
10 | *tview.Table
11 | }
12 |
13 | func NewTable() *Table {
14 | return &Table{
15 | Table: tview.NewTable(),
16 | }
17 | }
18 |
19 | func (t *Table) SetStyle(style *config.Styles) {
20 | t.SetBackgroundColor(style.Global.BackgroundColor.Color())
21 | t.SetBorderColor(style.Global.BorderColor.Color())
22 | t.SetTitleColor(style.Global.TitleColor.Color())
23 | t.SetFocusStyle(tcell.StyleDefault.Foreground(style.Global.FocusColor.Color()).Background(style.Global.BackgroundColor.Color()))
24 | }
25 |
26 | // MoveUpUntil moves the selection up until a condition is met
27 | func (t *Table) MoveUpUntil(row, col int, condition func(cell *tview.TableCell) bool) {
28 | for row > 0 {
29 | row--
30 | cell := t.GetCell(row, col)
31 | if condition(cell) {
32 | t.Select(row, col)
33 | return
34 | }
35 | }
36 | }
37 |
38 | // MoveDownUntil moves the selection down until a condition is met
39 | func (t *Table) MoveDownUntil(row, col int, condition func(cell *tview.TableCell) bool) {
40 | for row < t.GetRowCount()-1 {
41 | row++
42 | cell := t.GetCell(row, col)
43 | if condition(cell) {
44 | t.Select(row, col)
45 | return
46 | }
47 | }
48 | }
49 |
50 | // GetCellAboveThatMatch returns the cell above the current selection that matches the given condition
51 | func (t *Table) GetCellAboveThatMatch(row, col int, condition func(cell *tview.TableCell) bool) *tview.TableCell {
52 | for row > 0 {
53 | row--
54 | cell := t.GetCell(row, col)
55 | if condition(cell) {
56 | return cell
57 | }
58 | }
59 | return nil
60 | }
61 |
62 | // GetCellBelowThatMatch returns the cell below the current selection that matches the given condition
63 | func (t *Table) GetCellBelowThatMatch(row, col int, condition func(cell *tview.TableCell) bool) *tview.TableCell {
64 | for row < t.GetRowCount()-1 {
65 | row++
66 | cell := t.GetCell(row, col)
67 | if condition(cell) {
68 | return cell
69 | }
70 | }
71 | return nil
72 | }
73 |
74 | // GetContentFromRows returns the content of the table from the selected rows
75 | func (t *Table) GetContentFromRows(rows []int) []string {
76 | content := []string{}
77 | for _, row := range rows {
78 | content = append(content, t.GetCell(row, 0).GetReference().(string))
79 | }
80 | return content
81 | }
82 |
83 | // ImproveScrolling allows to scroll to the beginning and end of the table
84 | // while moving up and down when reaching the end of selectable area
85 | // but there are rows non selectable that we like to see
86 | // TODO: Implement
87 | func (t *Table) ImproveScrolling() {
88 | }
89 |
--------------------------------------------------------------------------------
/internal/tui/modal/style_change.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/vi-mongo/internal/config"
6 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
7 | "github.com/kopecmaciej/vi-mongo/internal/tui/primitives"
8 | )
9 |
10 | const (
11 | StyleChangeModal = "StyleChangeModal"
12 | )
13 |
14 | type StyleChange struct {
15 | *core.BaseElement
16 | *primitives.ListModal
17 |
18 | style *config.StyleChangeStyle
19 | applyStyle func(styleName string) error
20 | }
21 |
22 | func NewStyleChangeModal() *StyleChange {
23 | sc := &StyleChange{
24 | BaseElement: core.NewBaseElement(),
25 | ListModal: primitives.NewListModal(),
26 | }
27 |
28 | sc.SetIdentifier(StyleChangeModal)
29 | sc.SetAfterInitFunc(sc.init)
30 |
31 | return sc
32 | }
33 |
34 | func (sc *StyleChange) init() error {
35 | sc.setLayout()
36 | sc.setStyle()
37 | sc.setKeybindings()
38 | sc.setContent()
39 |
40 | return nil
41 | }
42 |
43 | func (sc *StyleChange) setLayout() {
44 | sc.SetTitle(" Change Style ")
45 | sc.SetBorder(true)
46 | sc.ShowSecondaryText(false)
47 | sc.SetBorderPadding(0, 0, 1, 1)
48 | }
49 |
50 | func (sc *StyleChange) setStyle() {
51 | sc.style = &sc.App.GetStyles().StyleChange
52 | globalBackground := sc.App.GetStyles().Global.BackgroundColor.Color()
53 |
54 | mainStyle := tcell.StyleDefault.
55 | Foreground(sc.style.TextColor.Color()).
56 | Background(globalBackground)
57 | sc.SetMainTextStyle(mainStyle)
58 |
59 | selectedStyle := tcell.StyleDefault.
60 | Foreground(sc.style.SelectedTextColor.Color()).
61 | Background(sc.style.SelectedBackgroundColor.Color())
62 | sc.SetSelectedStyle(selectedStyle)
63 | }
64 |
65 | func (sc *StyleChange) setKeybindings() {
66 | sc.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
67 | switch event.Key() {
68 | case tcell.KeyEscape, tcell.KeyCtrlT:
69 | sc.App.Pages.RemovePage(StyleChangeModal)
70 | return nil
71 | case tcell.KeyEnter:
72 | sc.App.Pages.RemovePage(StyleChangeModal)
73 | sc.applyStyle(sc.GetText())
74 | sc.setStyle()
75 | return nil
76 | }
77 | switch event.Rune() {
78 | case 'l':
79 | return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
80 | case 'h':
81 | return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
82 | }
83 | return event
84 | })
85 | }
86 |
87 | func (sc *StyleChange) setContent() {
88 | allStyles, err := config.GetAllStyles()
89 | if err != nil {
90 | ShowError(sc.App.Pages, "Failed to load styles", err)
91 | return
92 | }
93 |
94 | for i, style := range allStyles {
95 | rune := 49 + i
96 | sc.AddItem(style, "", int32(rune), nil)
97 | }
98 | }
99 |
100 | func (sc *StyleChange) SetApplyStyle(applyStyle func(styleName string) error) {
101 | sc.applyStyle = applyStyle
102 | }
103 |
104 | func (sc *StyleChange) Render() {
105 | sc.App.Pages.AddPage(StyleChangeModal, sc, true, true)
106 | }
107 |
--------------------------------------------------------------------------------
/internal/tui/component/tab_bar.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | "github.com/kopecmaciej/vi-mongo/internal/manager"
7 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
8 | )
9 |
10 | const (
11 | TabBarId = "TabBar"
12 | )
13 |
14 | type TabBarPrimitive interface {
15 | tview.Primitive
16 | Render()
17 | }
18 |
19 | type TabBarComponent struct {
20 | id string
21 | primitive TabBarPrimitive
22 | rendered bool
23 | }
24 |
25 | type TabBar struct {
26 | *core.BaseElement
27 | *core.Table
28 |
29 | active int
30 | tabs []*TabBarComponent
31 | }
32 |
33 | func NewTabBar() *TabBar {
34 | t := &TabBar{
35 | BaseElement: core.NewBaseElement(),
36 | Table: core.NewTable(),
37 | tabs: []*TabBarComponent{},
38 | }
39 |
40 | t.SetIdentifier(TabBarId)
41 | t.SetAfterInitFunc(t.init)
42 |
43 | return t
44 | }
45 |
46 | func (t *TabBar) init() error {
47 | t.setLayout()
48 | t.setStyle()
49 |
50 | t.handleEvents()
51 | return nil
52 | }
53 |
54 | func (t *TabBar) setStyle() {
55 | styles := t.App.GetStyles()
56 | t.SetStyle(styles)
57 | }
58 |
59 | func (t *TabBar) setLayout() {
60 | t.SetBorderPadding(0, 0, 1, 0)
61 | }
62 |
63 | func (t *TabBar) handleEvents() {
64 | go t.HandleEvents(TabBarId, func(event manager.EventMsg) {
65 | switch event.Message.Type {
66 | case manager.StyleChanged:
67 | t.setStyle()
68 | t.Render()
69 | }
70 | })
71 | }
72 |
73 | func (t *TabBar) AddTab(name string, component TabBarPrimitive, defaultTab bool) {
74 | t.tabs = append(t.tabs, &TabBarComponent{
75 | id: name,
76 | primitive: component,
77 | })
78 | if defaultTab {
79 | t.active = len(t.tabs) - 1
80 | }
81 | t.Render()
82 | }
83 |
84 | func (t *TabBar) NextTab() {
85 | if t.active < len(t.tabs)-1 {
86 | t.active++
87 | }
88 | t.Render()
89 | }
90 |
91 | func (t *TabBar) PreviousTab() {
92 | if t.active > 0 {
93 | t.active--
94 | }
95 | t.Render()
96 | }
97 |
98 | func (t *TabBar) Render() {
99 | styles := t.App.GetStyles()
100 | t.Clear()
101 | for i, tab := range t.tabs {
102 | cell := tview.NewTableCell(" " + tab.id + " ")
103 | if i == t.active {
104 | cell.SetTextColor(styles.TabBar.ActiveTextColor.Color())
105 | cell.SetAttributes(tcell.AttrBold)
106 | cell.SetBackgroundColor(styles.TabBar.ActiveBackgroundColor.Color())
107 |
108 | } else {
109 | cell.SetTextColor(styles.Global.TextColor.Color())
110 | }
111 | t.SetCell(0, i, cell)
112 | }
113 | }
114 |
115 | func (t *TabBar) GetActiveComponent() TabBarPrimitive {
116 | return t.tabs[t.active].primitive
117 | }
118 |
119 | func (t *TabBar) GetActiveComponentAndRender() TabBarPrimitive {
120 | component := t.tabs[t.active]
121 | if !component.rendered {
122 | component.primitive.Render()
123 | component.rendered = true
124 | }
125 | return component.primitive
126 | }
127 |
128 | func (t *TabBar) GetActiveTabIndex() int {
129 | return t.active
130 | }
131 |
--------------------------------------------------------------------------------
/internal/tui/primitives/input_modal.go:
--------------------------------------------------------------------------------
1 | package primitives
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | )
7 |
8 | // InputModal is a simple input field primitive that is displayed as a modal
9 | type InputModal struct {
10 | *tview.Box
11 |
12 | input *tview.InputField
13 | label string
14 | }
15 |
16 | // NewInputModal returns a new input field.
17 | func NewInputModal() *InputModal {
18 | mi := &InputModal{
19 | Box: tview.NewBox(),
20 | input: tview.NewInputField(),
21 | }
22 |
23 | return mi
24 | }
25 |
26 | // Draw draws this primitive onto the screen.
27 | func (mi *InputModal) Draw(screen tcell.Screen) {
28 | screenWidth, screenHeight := screen.Size()
29 |
30 | minWidth, minHeight := 50, 6
31 | // Calculate the width and height of the popup
32 | width, height := screenWidth/5, screenHeight/6
33 | if width < minWidth {
34 | width = minWidth
35 | }
36 | if height < minHeight {
37 | height = minHeight
38 | }
39 |
40 | // Calculate the position of the popup (centered)
41 | x, y := (screenWidth-width)/2, (screenHeight-height)/2
42 |
43 | // Set the position and size of the ModalInput
44 | mi.Box.SetRect(x, y, width, height)
45 |
46 | // Draw the box for the ModalInput
47 | mi.Box.DrawForSubclass(screen, mi.input)
48 |
49 | // Adjust the position and size of the input field within the box
50 | inputX, inputY, inputWidth, _ := mi.GetInnerRect()
51 |
52 | tview.Print(screen, mi.label, inputX, inputY, inputWidth, tview.AlignCenter, tcell.ColorYellow)
53 |
54 | inputY += 2
55 | inputX = inputX + 2
56 | inputWidth = inputWidth - 4
57 | mi.input.SetRect(inputX, inputY, inputWidth, 1)
58 | mi.input.Draw(screen)
59 | }
60 |
61 | func (mi *InputModal) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
62 | return mi.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
63 | mi.input.InputHandler()(event, setFocus)
64 | })
65 | }
66 |
67 | func (mi *InputModal) SetText(text string) *InputModal {
68 | mi.input.SetText(text)
69 | return mi
70 | }
71 |
72 | func (mi *InputModal) GetText() string {
73 | return mi.input.GetText()
74 | }
75 |
76 | func (mi *InputModal) SetLabel(label string) *InputModal {
77 | mi.label = label
78 | return mi
79 | }
80 |
81 | func (mi *InputModal) SetInputLabel(label string) *InputModal {
82 | mi.input.SetLabel(label)
83 | return mi
84 | }
85 |
86 | func (mi *InputModal) SetLabelColor(color tcell.Color) *InputModal {
87 | mi.input.SetLabelColor(color)
88 | return mi
89 | }
90 |
91 | func (mi *InputModal) SetFieldBackgroundColor(color tcell.Color) *InputModal {
92 | mi.input.SetFieldBackgroundColor(color)
93 | return mi
94 | }
95 |
96 | func (mi *InputModal) SetFieldTextColor(color tcell.Color) *InputModal {
97 | mi.input.SetFieldTextColor(color)
98 | return mi
99 | }
100 |
101 | func (mi *InputModal) SetBackgroundColor(color tcell.Color) *InputModal {
102 | mi.Box.SetBackgroundColor(color)
103 | return mi
104 | }
105 |
--------------------------------------------------------------------------------
/internal/ai/anthropic_driver.go:
--------------------------------------------------------------------------------
1 | package ai
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 |
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | const anthropicAPIURL = "https://api.anthropic.com/v1/messages"
14 |
15 | type AnthropicDriver struct {
16 | apiKey string
17 | systemMessage string
18 | }
19 |
20 | func NewAnthropicDriver(apiKey string) *AnthropicDriver {
21 | return &AnthropicDriver{
22 | apiKey: apiKey,
23 | }
24 | }
25 |
26 | func (d *AnthropicDriver) SetSystemMessage(message string) {
27 | d.systemMessage = message
28 | }
29 |
30 | func (d *AnthropicDriver) GetResponse(prompt string, model string) (string, error) {
31 | requestBody, err := json.Marshal(map[string]any{
32 | "model": model,
33 | "max_tokens": 1024,
34 | "system": d.systemMessage,
35 | "messages": []map[string]string{
36 | {"role": "user", "content": prompt},
37 | },
38 | })
39 | if err != nil {
40 | log.Error().Err(err).Msg("Error marshaling request body")
41 | return "", fmt.Errorf("error marshaling request body: %w", err)
42 | }
43 |
44 | req, err := http.NewRequest("POST", anthropicAPIURL, bytes.NewBuffer(requestBody))
45 | if err != nil {
46 | log.Error().Err(err).Msg("Error creating anthropic request")
47 | return "", fmt.Errorf("error creating anthropic request: %w", err)
48 | }
49 |
50 | req.Header.Set("Content-Type", "application/json")
51 | req.Header.Set("X-API-Key", d.apiKey)
52 | req.Header.Set("anthropic-version", "2023-06-01")
53 |
54 | client := &http.Client{}
55 | resp, err := client.Do(req)
56 | if err != nil {
57 | log.Error().Err(err).Msg("Error sending request")
58 | return "", fmt.Errorf("error sending request: %w", err)
59 | }
60 |
61 | defer func() {
62 | if err := resp.Body.Close(); err != nil {
63 | log.Error().Err(err).Msg("Error closing request body")
64 | }
65 | }()
66 |
67 | body, err := io.ReadAll(resp.Body)
68 | if err != nil {
69 | log.Error().Err(err).Msg("Error reading response body")
70 | return "", fmt.Errorf("error reading response body: %w", err)
71 | }
72 |
73 | if resp.StatusCode != http.StatusOK {
74 | log.Error().Err(err).Msgf("API request failed with status code %d: body: %s", resp.StatusCode, string(body))
75 | return "", fmt.Errorf("api request failed with status code %d", resp.StatusCode)
76 | }
77 |
78 | var result struct {
79 | Content []struct {
80 | Text string `json:"text"`
81 | } `json:"content"`
82 | }
83 | err = json.Unmarshal(body, &result)
84 | if err != nil {
85 | log.Error().Err(err).Msg("Error unmarshaling response")
86 | return "", fmt.Errorf("error unmarshaling response: %w", err)
87 | }
88 |
89 | if len(result.Content) == 0 || result.Content[0].Text == "" {
90 | log.Error().Msg("Unexpected response format")
91 | return "", fmt.Errorf("unexpected response format")
92 | }
93 |
94 | return result.Content[0].Text, nil
95 | }
96 |
97 | func GetAnthropicModels() ([]string, int) {
98 | models := []string{"claude-3-opus-20240229", "claude-3-haiku-20240307", "claude-3-sonnet-20240229"}
99 | defaultModelIndex := 2
100 | return models, defaultModelIndex
101 | }
102 |
--------------------------------------------------------------------------------
/internal/tui/core/app.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/kopecmaciej/tview"
5 | "github.com/kopecmaciej/vi-mongo/internal/config"
6 | "github.com/kopecmaciej/vi-mongo/internal/manager"
7 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
8 | "github.com/rs/zerolog/log"
9 | )
10 |
11 | type (
12 | // App is a main application struct
13 | App struct {
14 | *tview.Application
15 |
16 | Pages *Pages
17 | dao *mongo.Dao
18 | manager *manager.ElementManager
19 | styles *config.Styles
20 | config *config.Config
21 | keys *config.KeyBindings
22 | previousFocus tview.Primitive
23 | }
24 | )
25 |
26 | func NewApp(appConfig *config.Config) *App {
27 | styles, err := config.LoadStyles(appConfig.Styles.CurrentStyle, appConfig.Styles.BetterSymbols)
28 | if err != nil {
29 | log.Fatal().Err(err).Msg("Failed to load styles")
30 | }
31 | styles.LoadMainStyles()
32 | keyBindings, err := config.LoadKeybindings()
33 | if err != nil {
34 | log.Fatal().Err(err).Msg("Failed to load keybindings")
35 | }
36 |
37 | app := &App{
38 | Application: tview.NewApplication(),
39 | manager: manager.NewElementManager(),
40 | styles: styles,
41 | config: appConfig,
42 | keys: keyBindings,
43 | }
44 |
45 | app.Pages = NewPages(app.manager, app)
46 | app.Pages.SetStyle(styles)
47 |
48 | return app
49 | }
50 |
51 | func (a *App) SetStyle(styleName string) error {
52 | a.config.Styles.CurrentStyle = styleName
53 | err := a.config.UpdateConfig()
54 | if err != nil {
55 | return err
56 | }
57 |
58 | a.styles, err = config.LoadStyles(a.config.Styles.CurrentStyle, a.config.Styles.BetterSymbols)
59 | if err != nil {
60 | return err
61 | }
62 | a.styles.LoadMainStyles()
63 | a.Pages.SetStyle(a.styles)
64 | a.manager.Broadcast(manager.EventMsg{
65 | Message: manager.Message{
66 | Type: manager.StyleChanged,
67 | },
68 | })
69 |
70 | return nil
71 | }
72 |
73 | func (a *App) SetPreviousFocus() {
74 | a.previousFocus = a.GetFocus()
75 | }
76 |
77 | func (a *App) SetFocus(p tview.Primitive) {
78 | a.previousFocus = a.GetFocus()
79 | a.Application.SetFocus(p)
80 | a.FocusChanged(p)
81 | }
82 |
83 | func (a *App) GiveBackFocus() {
84 | if a.previousFocus != nil {
85 | a.SetFocus(a.previousFocus)
86 | a.previousFocus = nil
87 | }
88 | }
89 |
90 | // FocusChanged is a callback that is called when the focus is changed
91 | // it is used to update the keys
92 | func (a *App) FocusChanged(p tview.Primitive) {
93 | msg := manager.EventMsg{
94 | Message: manager.Message{
95 | Type: manager.FocusChanged,
96 | Data: p.GetIdentifier(),
97 | },
98 | }
99 | a.manager.Broadcast(msg)
100 | }
101 |
102 | func (a *App) GetDao() *mongo.Dao {
103 | return a.dao
104 | }
105 |
106 | func (a *App) SetDao(dao *mongo.Dao) {
107 | a.dao = dao
108 | }
109 |
110 | func (a *App) GetManager() *manager.ElementManager {
111 | return a.manager
112 | }
113 |
114 | func (a *App) GetKeys() *config.KeyBindings {
115 | return a.keys
116 | }
117 |
118 | func (a *App) GetStyles() *config.Styles {
119 | return a.styles
120 | }
121 |
122 | func (a *App) GetConfig() *config.Config {
123 | return a.config
124 | }
125 |
--------------------------------------------------------------------------------
/internal/tui/primitives/list_modal.go:
--------------------------------------------------------------------------------
1 | package primitives
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | )
7 |
8 | // ListModal is a simple list primitive that is displayed as a modal
9 | type ListModal struct {
10 | *tview.Box
11 |
12 | list *tview.List
13 | }
14 |
15 | func NewListModal() *ListModal {
16 | return &ListModal{
17 | Box: tview.NewBox(),
18 | list: tview.NewList(),
19 | }
20 | }
21 |
22 | // Draw draws this primitive onto the screen.
23 | func (lm *ListModal) Draw(screen tcell.Screen) {
24 | screenWidth, screenHeight := screen.Size()
25 |
26 | // Calculate the default width of the modal
27 | width, height := screenWidth/2, screenHeight/2
28 |
29 | // Calculate the position of the popup (centered)
30 | x := (screenWidth - width) / 2
31 | y := (screenHeight - height) / 2
32 |
33 | lm.SetRect(x, y, width, height)
34 |
35 | lm.Box.DrawForSubclass(screen, lm)
36 |
37 | // add padding to the list
38 | x, y, width, height = x+1, y+1, width-2, height-2
39 |
40 | // Set the list's dimensions and position and draw it
41 | lm.list.SetRect(x, y, width, height)
42 | lm.list.Draw(screen)
43 | }
44 |
45 | // GetText returns text of the selected item
46 | func (lm *ListModal) GetText() string {
47 | selected := lm.list.GetCurrentItem()
48 | mainText, _ := lm.list.GetItemText(selected)
49 | return mainText
50 | }
51 |
52 | // SetBorderPadding sets the border padding of the modal
53 | func (lm *ListModal) SetBorderPadding(top, right, bottom, left int) *ListModal {
54 | lm.list.SetBorderPadding(top, right, bottom, left)
55 | return lm
56 | }
57 |
58 | // AddItem adds item to the list
59 | func (lm *ListModal) AddItem(text string, secondaryText string, shortcut rune, selected func()) *ListModal {
60 | lm.list.AddItem(text, secondaryText, shortcut, selected)
61 | return lm
62 | }
63 |
64 | // RemoveItem removes item from the list
65 | func (lm *ListModal) RemoveItem(index int) *ListModal {
66 | lm.list.RemoveItem(index)
67 | return lm
68 | }
69 |
70 | // GetCurrentItem returns the index of the currently selected item
71 | func (lm *ListModal) GetCurrentItem() int {
72 | return lm.list.GetCurrentItem()
73 | }
74 |
75 | // Clear removes all items from the list
76 | func (lm *ListModal) Clear() *ListModal {
77 | lm.list.Clear()
78 | return lm
79 | }
80 |
81 | // ShowSecondaryText sets whether or not secondary text is shown
82 | func (lm *ListModal) ShowSecondaryText(show bool) *ListModal {
83 | lm.list.ShowSecondaryText(show)
84 | return lm
85 | }
86 |
87 | // SetMainTextStyle sets the text style of main text.
88 | func (lm *ListModal) SetMainTextStyle(style tcell.Style) *ListModal {
89 | lm.list.SetMainTextStyle(style)
90 | return lm
91 | }
92 |
93 | // SetSecondaryTextStyle sets the text style of secondary text.
94 | func (lm *ListModal) SetSecondaryTextStyle(style tcell.Style) *ListModal {
95 | lm.list.SetSecondaryTextStyle(style)
96 | return lm
97 | }
98 |
99 | // SetSelectedTextColor sets the color of the selected item's text.
100 | func (lm *ListModal) SetSelectedStyle(style tcell.Style) *ListModal {
101 | lm.list.SetSelectedStyle(style)
102 | return lm
103 | }
104 |
105 | // InputHandler returns the handler for this primitive.
106 | func (lm *ListModal) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
107 | return lm.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
108 | lm.list.InputHandler()(event, setFocus)
109 | })
110 | }
111 |
--------------------------------------------------------------------------------
/internal/tui/primitives/form_modal.go:
--------------------------------------------------------------------------------
1 | package primitives
2 |
3 | import (
4 | "github.com/gdamore/tcell/v2"
5 | "github.com/kopecmaciej/tview"
6 | )
7 |
8 | // FormModal is a modal window that contains a form.
9 | type FormModal struct {
10 | *tview.Box
11 | Form *tview.Form
12 | done func(buttonIndex int, buttonLabel string)
13 | cancel func()
14 | }
15 |
16 | // NewFormModal returns a new form modal.
17 | func NewFormModal() *FormModal {
18 | m := &FormModal{
19 | Box: tview.NewBox(),
20 | }
21 | m.Form = tview.NewForm().
22 | SetButtonsAlign(tview.AlignCenter).
23 | SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
24 | SetButtonTextColor(tview.Styles.PrimaryTextColor)
25 | m.Form.SetBackgroundColor(tview.Styles.ContrastBackgroundColor).SetBorderPadding(0, 0, 0, 0)
26 |
27 | return m
28 | }
29 |
30 | // GetForm returns the form.
31 | func (m *FormModal) GetForm() *tview.Form {
32 | return m.Form
33 | }
34 |
35 | // SetDoneFunc sets a handler which is called when one of the buttons was
36 | // pressed. It receives the index of the button as well as its label text.
37 | func (m *FormModal) SetDoneFunc(handler func(buttonIndex int, buttonLabel string)) *FormModal {
38 | m.done = handler
39 | return m
40 | }
41 |
42 | // SetCancelFunc sets a handler which is called when the user cancels the modal.
43 | func (m *FormModal) SetCancelFunc(handler func()) *FormModal {
44 | m.cancel = handler
45 | return m
46 | }
47 |
48 | // Draw draws this primitive onto the screen.
49 | func (m *FormModal) Draw(screen tcell.Screen) {
50 | screenWidth, screenHeight := screen.Size()
51 |
52 | width, height := screenWidth/2, screenHeight/2
53 |
54 | // Calculate the position of the popup (centered)
55 | x := (screenWidth - width) / 2
56 | y := (screenHeight - height) / 2
57 |
58 | m.SetRect(x, y, width, height)
59 |
60 | m.Box.DrawForSubclass(screen, m)
61 |
62 | // add padding
63 | x, y, width, height = x+1, y+1, width-2, height-2
64 |
65 | m.Form.SetRect(x, y, width, height)
66 |
67 | m.Form.Draw(screen)
68 | }
69 |
70 | // Focus is called when this primitive receives focus.
71 | func (m *FormModal) Focus(delegate func(p tview.Primitive)) {
72 | delegate(m.Form)
73 | }
74 |
75 | // HasFocus returns whether or not this primitive has focus.
76 | func (m *FormModal) HasFocus() bool {
77 | return m.Form.HasFocus()
78 | }
79 |
80 | // MouseHandler returns the mouse handler for this primitive.
81 | func (m *FormModal) MouseHandler() func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
82 | return m.WrapMouseHandler(func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive)) (consumed bool, capture tview.Primitive) {
83 | if !m.InRect(event.Position()) {
84 | return false, nil
85 | }
86 |
87 | consumed, capture = m.Form.MouseHandler()(action, event, setFocus)
88 | if consumed {
89 | setFocus(m)
90 | }
91 | return
92 | })
93 | }
94 |
95 | // InputHandler returns the handler for this primitive.
96 | func (m *FormModal) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
97 | return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
98 | if event.Key() == tcell.KeyEscape {
99 | if m.cancel != nil {
100 | m.cancel()
101 | }
102 | return
103 | }
104 | if handler := m.Form.InputHandler(); handler != nil {
105 | handler(event, setFocus)
106 | }
107 | })
108 | }
109 |
--------------------------------------------------------------------------------
/internal/tui/core/element.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/kopecmaciej/tview"
7 | "github.com/kopecmaciej/vi-mongo/internal/manager"
8 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
9 | )
10 |
11 | // BaseElement is a base struct for all visable elements.
12 | // It contains all the basic fields and functions that are used by all visable elements.
13 | type BaseElement struct {
14 | // enabled is a flag that indicates if the view is enabled.
15 | enabled bool
16 |
17 | // App is a pointer to the main App.
18 | // It's used for accessing App focus, root page etc.
19 | App *App
20 |
21 | // dao is a pointer to the mongo dao.
22 | Dao *mongo.Dao
23 |
24 | // afterInitFunc is a function that is called when the view is initialized.
25 | // It's main purpose is to run all the initialization functions of the subviews.
26 | afterInitFunc func() error
27 |
28 | // Listener is a channel that is used to receive events from the app.
29 | Listener chan manager.EventMsg
30 |
31 | // mutex is a mutex that is used to synchronize the view.
32 | mutex sync.Mutex
33 | }
34 |
35 | // NewBaseElement is a constructor for the BaseElement struct.
36 | func NewBaseElement() *BaseElement {
37 | return &BaseElement{}
38 | }
39 |
40 | // Init is a function that is called when the view is initialized.
41 | // If custom initialization is needed, this function should be overriden.
42 | func (c *BaseElement) Init(app *App) error {
43 | if c.App != nil {
44 | return nil
45 | }
46 |
47 | c.App = app
48 | if app.GetDao() != nil {
49 | c.Dao = app.GetDao()
50 | }
51 |
52 | if c.afterInitFunc != nil {
53 | return c.afterInitFunc()
54 | }
55 |
56 | return nil
57 | }
58 |
59 | // UpdateDao updates the dao in the element
60 | func (c *BaseElement) UpdateDao(dao *mongo.Dao) {
61 | c.Dao = dao
62 | }
63 |
64 | // Enable sets the enabled flag.
65 | func (c *BaseElement) Enable() {
66 | c.mutex.Lock()
67 | defer c.mutex.Unlock()
68 | c.enabled = true
69 | }
70 |
71 | // Disable unsets the enabled flag.
72 | func (c *BaseElement) Disable() {
73 | c.mutex.Lock()
74 | defer c.mutex.Unlock()
75 | c.enabled = false
76 | }
77 |
78 | // Toggle toggles the enabled flag.
79 | func (c *BaseElement) Toggle() {
80 | if c.IsEnabled() {
81 | c.Disable()
82 | } else {
83 | c.Enable()
84 | }
85 | }
86 |
87 | // IsEnabled returns the enabled flag.
88 | func (c *BaseElement) IsEnabled() bool {
89 | c.mutex.Lock()
90 | defer c.mutex.Unlock()
91 | return c.enabled
92 | }
93 |
94 | // Broadcast sends an event to all listeners.
95 | func (c *BaseElement) BroadcastEvent(event manager.EventMsg) {
96 | c.App.GetManager().Broadcast(event)
97 | }
98 |
99 | // SendToElement sends an event to the element.
100 | func (c *BaseElement) SendToElement(element tview.Identifier, event manager.EventMsg) {
101 | c.App.GetManager().SendTo(element, event)
102 | }
103 |
104 | // SetAfterInitFunc sets the optional function that will be run at the end of the Init function.
105 | func (c *BaseElement) SetAfterInitFunc(afterInitFunc func() error) {
106 | c.afterInitFunc = afterInitFunc
107 | }
108 |
109 | // Subscribe subscribes to the view events.
110 | func (c *BaseElement) Subscribe(identifier tview.Identifier) {
111 | c.Listener = c.App.GetManager().Subscribe(identifier)
112 | }
113 |
114 | // HandleEvents handles events from the manager
115 | func (c *BaseElement) HandleEvents(identifier tview.Identifier, handler func(event manager.EventMsg)) {
116 | if c.Listener == nil {
117 | c.Listener = c.App.GetManager().Subscribe(identifier)
118 | }
119 | for event := range c.Listener {
120 | handler(event)
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/internal/util/mongo.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "sort"
7 | "time"
8 |
9 | "github.com/kopecmaciej/tview"
10 | "go.mongodb.org/mongo-driver/bson/primitive"
11 | )
12 |
13 | const (
14 | TypeString = "String"
15 | TypeInt32 = "Int32"
16 | TypeInt64 = "Int64"
17 | TypeDouble = "Double"
18 | TypeBool = "Bool"
19 | TypeObjectId = "ObjectID"
20 | TypeDate = "Date"
21 | TypeTimestamp = "Timestamp"
22 | TypeArray = "Array"
23 | TypeObject = "Object"
24 | TypeRegex = "Regex"
25 | TypeBinary = "Binary"
26 | TypeMinKey = "MinKey"
27 | TypeMaxKey = "MaxKey"
28 | TypeMixed = "Mixed"
29 | TypeNull = "Null"
30 | TypeUndefined = "Undefined"
31 | )
32 |
33 | func GetSortedKeysWithTypes(documents []primitive.M, typeColor string) []string {
34 | keys := make(map[string]string)
35 | for _, doc := range documents {
36 | for k, v := range doc {
37 | if _, exists := keys[k]; exists && keys[k] != GetMongoType(v) {
38 | keys[k] = TypeMixed
39 | } else {
40 | keys[k] = GetMongoType(v)
41 | }
42 | }
43 | }
44 |
45 | // Sort the keys for consistent column order
46 | sortedKeys := make([]string, 0, len(keys))
47 | for k, t := range keys {
48 | sortedKeys = append(sortedKeys, fmt.Sprintf("%s [%s]%s", k, typeColor, t))
49 | }
50 | sort.Strings(sortedKeys)
51 |
52 | return sortedKeys
53 | }
54 |
55 | // StringifyMongoValueByType converts a value to a string
56 | func StringifyMongoValueByType(v any) string {
57 | switch t := v.(type) {
58 | case string:
59 | return t
60 | case int32, int64:
61 | return fmt.Sprintf("%d", t)
62 | case float32, float64:
63 | return fmt.Sprintf("%g", t)
64 | case bool:
65 | return fmt.Sprintf("%t", t)
66 | case primitive.ObjectID:
67 | return t.Hex()
68 | case primitive.DateTime:
69 | return t.Time().UTC().Format(time.RFC3339)
70 | case primitive.A, primitive.D, primitive.M, map[string]any, []any:
71 | b, _ := json.Marshal(t)
72 | // Use tview's Escape function to prevent brackets from being interpreted as color tags
73 | return tview.Escape(string(b))
74 | case primitive.E:
75 | return fmt.Sprintf("%v", t)
76 | case primitive.Binary:
77 | return fmt.Sprintf("%v", t)
78 | case primitive.Regex:
79 | return fmt.Sprintf("%v", t)
80 | case primitive.Undefined:
81 | return fmt.Sprintf("%v", t)
82 | case primitive.MinKey:
83 | return fmt.Sprintf("%v", t)
84 | case primitive.MaxKey:
85 | return fmt.Sprintf("%v", t)
86 | default:
87 | return "null"
88 | }
89 | }
90 |
91 | // Helper function to determine MongoDB type
92 | func GetMongoType(v any) string {
93 | switch v.(type) {
94 | case string:
95 | return TypeString
96 | case int32:
97 | return TypeInt32
98 | case int64:
99 | return TypeInt64
100 | case float32, float64:
101 | return TypeDouble
102 | case bool:
103 | return TypeBool
104 | case primitive.ObjectID:
105 | return TypeObjectId
106 | case primitive.DateTime:
107 | return TypeDate
108 | case primitive.Timestamp:
109 | return TypeTimestamp
110 | case primitive.Regex:
111 | return TypeRegex
112 | case primitive.Binary:
113 | return TypeBinary
114 | case primitive.MinKey:
115 | return TypeMinKey
116 | case primitive.MaxKey:
117 | return TypeMaxKey
118 | case primitive.Undefined:
119 | return TypeUndefined
120 | case primitive.A:
121 | return TypeArray
122 | case primitive.D, primitive.M:
123 | return TypeObject
124 | default:
125 | return TypeNull
126 | }
127 | }
128 |
129 | func DeepCopy(v primitive.M) primitive.M {
130 | copy := make(primitive.M)
131 | for k, v := range v {
132 | copy[k] = v
133 | }
134 | return copy
135 | }
136 |
--------------------------------------------------------------------------------
/internal/util/encrypt.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/aes"
5 | "crypto/cipher"
6 | "crypto/rand"
7 | "encoding/hex"
8 | "fmt"
9 | "io"
10 | "os"
11 | )
12 |
13 | type EncryptionError struct {
14 | Operation string
15 | Err error
16 | }
17 |
18 | func (e *EncryptionError) Error() string {
19 | return fmt.Sprintf("encryption error during %s: %v", e.Operation, e.Err)
20 | }
21 |
22 | func (e *EncryptionError) Unwrap() error {
23 | return e.Err
24 | }
25 |
26 | const (
27 | EncryptionKeyEnv = "VI_MONGO_SECRET_KEY"
28 |
29 | KeyLength = 32 // AES-256
30 | )
31 |
32 | func GenerateEncryptionKey() (string, error) {
33 | key := make([]byte, KeyLength)
34 | _, err := rand.Read(key)
35 | if err != nil {
36 | return "", &EncryptionError{Operation: "key generation", Err: err}
37 | }
38 |
39 | encodedKey := hex.EncodeToString(key)
40 | return encodedKey, nil
41 | }
42 |
43 | func PrintEncryptionKeyInstructions() {
44 | key, err := GenerateEncryptionKey()
45 | if err != nil {
46 | fmt.Fprintf(os.Stderr, "Failed to generate encryption key: %v\n", err)
47 | return
48 | }
49 |
50 | fmt.Println("Encryption key successfully generated for vi-mongo:")
51 | fmt.Println(key)
52 | fmt.Println("\nPlease store this key securely using one of the following methods:")
53 |
54 | fmt.Println("- Set it as an environment variable: VI_MONGO_SECRET_KEY")
55 | fmt.Println("- Save it to a file and reference the path in the config file")
56 | fmt.Println(" or use the CLI option: vi-mongo --secret-key=/path/to/key")
57 | }
58 |
59 | func GetEncryptionKey() string {
60 | return os.Getenv(EncryptionKeyEnv)
61 | }
62 |
63 | // EncryptPassword encrypts the given password using the provided hex-encoded key.
64 | func EncryptPassword(password string, hexKey string) (string, error) {
65 | if password == "" {
66 | return "", nil
67 | }
68 |
69 | keyBytes, err := hex.DecodeString(hexKey)
70 | if err != nil {
71 | return "", &EncryptionError{Operation: "key decoding", Err: err}
72 | }
73 |
74 | block, err := aes.NewCipher(keyBytes)
75 | if err != nil {
76 | return "", &EncryptionError{Operation: "cipher creation", Err: err}
77 | }
78 |
79 | gcm, err := cipher.NewGCM(block)
80 | if err != nil {
81 | return "", &EncryptionError{Operation: "GCM creation", Err: err}
82 | }
83 |
84 | nonce := make([]byte, gcm.NonceSize())
85 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
86 | return "", &EncryptionError{Operation: "nonce generation", Err: err}
87 | }
88 |
89 | ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
90 | return hex.EncodeToString(ciphertext), nil
91 | }
92 |
93 | // DecryptPassword decrypts the hex-encoded encrypted password using the provided hex-encoded key.
94 | func DecryptPassword(encryptedHex string, hexKey string) (string, error) {
95 | if encryptedHex == "" {
96 | return "", nil
97 | }
98 |
99 | ciphertext, err := hex.DecodeString(encryptedHex)
100 | if err != nil {
101 | return "", &EncryptionError{Operation: "decode encrypted password", Err: err}
102 | }
103 |
104 | keyBytes, err := hex.DecodeString(hexKey)
105 | if err != nil {
106 | return "", &EncryptionError{Operation: "key decoding", Err: err}
107 | }
108 |
109 | block, err := aes.NewCipher(keyBytes)
110 | if err != nil {
111 | return "", &EncryptionError{Operation: "cipher creation", Err: err}
112 | }
113 |
114 | gcm, err := cipher.NewGCM(block)
115 | if err != nil {
116 | return "", &EncryptionError{Operation: "GCM creation", Err: err}
117 | }
118 |
119 | if len(ciphertext) < gcm.NonceSize() {
120 | return "", &EncryptionError{Operation: "ciphertext validation", Err: fmt.Errorf("ciphertext too short")}
121 | }
122 |
123 | nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
124 | plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
125 | if err != nil {
126 | return "", &EncryptionError{Operation: "password decryption", Err: err}
127 | }
128 |
129 | return string(plaintext), nil
130 | }
131 |
--------------------------------------------------------------------------------
/internal/tui/modal/query_options.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "context"
5 | "strconv"
6 | "strings"
7 |
8 | "github.com/gdamore/tcell/v2"
9 | "github.com/kopecmaciej/tview"
10 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
11 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
12 | )
13 |
14 | const (
15 | QueryOptionsModalId = "QueryOptionsModal"
16 | )
17 |
18 | type QueryOptionsModal struct {
19 | *core.BaseElement
20 | *core.FormModal
21 |
22 | applyCallback func()
23 | }
24 |
25 | func NewQueryOptionsModal() *QueryOptionsModal {
26 | qo := &QueryOptionsModal{
27 | BaseElement: core.NewBaseElement(),
28 | FormModal: core.NewFormModal(),
29 | }
30 |
31 | qo.SetIdentifier(StyleChangeModal)
32 | qo.SetAfterInitFunc(qo.init)
33 | return qo
34 | }
35 |
36 | func (qo *QueryOptionsModal) init() error {
37 | qo.setLayout()
38 | qo.setStyle()
39 | qo.setKeybindings()
40 |
41 | return nil
42 | }
43 |
44 | func (qo *QueryOptionsModal) setLayout() {
45 | qo.SetTitle(" Query Options ")
46 | qo.SetBorder(true)
47 | qo.SetTitleAlign(tview.AlignCenter)
48 | qo.Form.SetBorderPadding(2, 2, 2, 2)
49 | }
50 |
51 | func (qo *QueryOptionsModal) setStyle() {
52 | styles := qo.App.GetStyles()
53 | qo.SetStyle(styles)
54 |
55 | qo.Form.SetFieldTextColor(styles.Connection.FormInputColor.Color())
56 | qo.Form.SetFieldBackgroundColor(styles.Connection.FormInputBackgroundColor.Color())
57 | qo.Form.SetLabelColor(styles.Connection.FormLabelColor.Color())
58 | }
59 |
60 | func (qo *QueryOptionsModal) setKeybindings() {
61 | k := qo.App.GetKeys()
62 | qo.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
63 | switch event.Key() {
64 | case tcell.KeyEsc:
65 | qo.Hide()
66 | return nil
67 | }
68 | switch {
69 | case k.Contains(k.Content.ToggleQueryOptions, event.Name()):
70 | qo.Hide()
71 | return nil
72 | }
73 |
74 | return event
75 | })
76 | }
77 |
78 | func (qo *QueryOptionsModal) SetApplyCallback(callback func()) {
79 | qo.applyCallback = callback
80 | }
81 |
82 | func (qo *QueryOptionsModal) Render(ctx context.Context, state *mongo.CollectionState, defaultLimit int64) error {
83 | qo.Form.Clear(true)
84 |
85 | qo.Form.AddInputField("Projection", state.Projection, 40, nil, nil)
86 |
87 | limitStr := strconv.FormatInt(state.Limit, 10)
88 | qo.Form.AddInputField("Limit", limitStr, 20,
89 | func(textToCheck string, lastChar rune) bool {
90 | _, err := strconv.Atoi(textToCheck)
91 | return err == nil || textToCheck == ""
92 | }, nil)
93 |
94 | skipStr := strconv.FormatInt(state.Skip, 10)
95 | qo.Form.AddInputField("Skip", skipStr, 20,
96 | func(textToCheck string, lastChar rune) bool {
97 | _, err := strconv.Atoi(textToCheck)
98 | return err == nil || textToCheck == ""
99 | }, nil)
100 |
101 | qo.Form.AddButton("Apply", func() {
102 | limitText := qo.Form.GetFormItemByLabel("Limit").(*tview.InputField).GetText()
103 | skipText := qo.Form.GetFormItemByLabel("Skip").(*tview.InputField).GetText()
104 | projText := qo.Form.GetFormItemByLabel("Projection").(*tview.InputField).GetText()
105 |
106 | if strings.Trim(limitText, " ") != "" {
107 | val, err := strconv.ParseInt(limitText, 10, 64)
108 | if err != nil {
109 | ShowError(qo.App.Pages, "Invalid limit value", err)
110 | return
111 | }
112 | state.Limit = val
113 | } else {
114 | state.Limit = defaultLimit
115 | }
116 |
117 | if strings.Trim(skipText, " ") != "" {
118 | val, err := strconv.ParseInt(skipText, 10, 64)
119 | if err != nil {
120 | ShowError(qo.App.Pages, "Invalid skip value", err)
121 | return
122 | }
123 | state.Skip = val
124 | } else {
125 | state.Skip = 0
126 | }
127 |
128 | state.Projection = projText
129 |
130 | if qo.applyCallback != nil {
131 | qo.applyCallback()
132 | }
133 | })
134 |
135 | qo.Form.AddButton("Cancel", func() {
136 | qo.Hide()
137 | })
138 |
139 | qo.Show()
140 |
141 | return nil
142 | }
143 |
144 | func (qo *QueryOptionsModal) Show() {
145 | qo.App.Pages.AddPage(QueryOptionsModalId, qo, true, true)
146 | }
147 |
148 | func (qo *QueryOptionsModal) Hide() {
149 | qo.App.Pages.RemovePage(QueryOptionsModalId)
150 | }
151 |
--------------------------------------------------------------------------------
/internal/tui/core/others.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | // Here we define all the components that need to be styled
4 | // but they don't have any other methods implemented
5 |
6 | import (
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/tview"
9 | "github.com/kopecmaciej/vi-mongo/internal/config"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/primitives"
11 | )
12 |
13 | // Styler is an interface for components that can be styled
14 | type Styler interface {
15 | SetBackgroundColor(tcell.Color) *tview.Box
16 | SetBorderColor(tcell.Color) *tview.Box
17 | SetTitleColor(tcell.Color) *tview.Box
18 | SetFocusStyle(tcell.Style) *tview.Box
19 | }
20 |
21 | // SetCommonStyle applies common styling to any component implementing the Styler interface
22 | func SetCommonStyle(s Styler, style *config.Styles) {
23 | s.SetBackgroundColor(style.Global.BackgroundColor.Color())
24 | s.SetBorderColor(style.Global.BorderColor.Color())
25 | s.SetTitleColor(style.Global.TitleColor.Color())
26 | s.SetFocusStyle(tcell.StyleDefault.
27 | Foreground(style.Global.FocusColor.Color()).
28 | Background(style.Global.BackgroundColor.Color()))
29 | }
30 |
31 | // Define structs for each component type
32 | type (
33 | Flex struct {
34 | *tview.Flex
35 | }
36 | List struct {
37 | *tview.List
38 | }
39 | TextView struct {
40 | *tview.TextView
41 | }
42 | TreeView struct {
43 | *tview.TreeView
44 | }
45 | InputField struct {
46 | *tview.InputField
47 | }
48 | Modal struct {
49 | *tview.Modal
50 | }
51 | ViewModal struct {
52 | *primitives.ViewModal
53 | }
54 | ListModal struct {
55 | *primitives.ListModal
56 | }
57 | FormModal struct {
58 | *primitives.FormModal
59 | }
60 | )
61 |
62 | // Constructor functions
63 | func NewFlex() *Flex {
64 | return &Flex{Flex: tview.NewFlex()}
65 | }
66 |
67 | func NewList() *List {
68 | return &List{List: tview.NewList()}
69 | }
70 |
71 | func NewTextView() *TextView {
72 | return &TextView{TextView: tview.NewTextView()}
73 | }
74 |
75 | func NewTreeView() *TreeView {
76 | return &TreeView{TreeView: tview.NewTreeView()}
77 | }
78 |
79 | func NewInputField() *InputField {
80 | return &InputField{InputField: tview.NewInputField()}
81 | }
82 |
83 | func NewModal() *Modal {
84 | return &Modal{Modal: tview.NewModal()}
85 | }
86 |
87 | func NewViewModal() *ViewModal {
88 | return &ViewModal{ViewModal: primitives.NewViewModal()}
89 | }
90 |
91 | func NewListModal() *ListModal {
92 | return &ListModal{ListModal: primitives.NewListModal()}
93 | }
94 |
95 | func NewFormModal() *FormModal {
96 | return &FormModal{FormModal: primitives.NewFormModal()}
97 | }
98 |
99 | func (f *Flex) SetStyle(style *config.Styles) {
100 | SetCommonStyle(f.Flex, style)
101 | }
102 |
103 | func (l *List) SetStyle(style *config.Styles) {
104 | SetCommonStyle(l.List, style)
105 | }
106 |
107 | func (t *TextView) SetStyle(style *config.Styles) {
108 | SetCommonStyle(t.TextView, style)
109 | t.SetTextColor(style.Global.TextColor.Color())
110 | }
111 |
112 | func (t *TreeView) SetStyle(style *config.Styles) {
113 | SetCommonStyle(t.TreeView, style)
114 | }
115 |
116 | func (i *InputField) SetStyle(style *config.Styles) {
117 | SetCommonStyle(i.InputField, style)
118 | i.SetLabelStyle(tcell.StyleDefault.Foreground(style.Global.TextColor.Color()).Background(style.Global.BackgroundColor.Color()))
119 | i.SetFieldBackgroundColor(style.Global.BackgroundColor.Color())
120 | i.SetFieldTextColor(style.Global.TextColor.Color())
121 | i.SetPlaceholderTextColor(style.Global.TextColor.Color())
122 | }
123 |
124 | func (m *Modal) SetStyle(style *config.Styles) {
125 | SetCommonStyle(m.Box, style)
126 | m.SetBackgroundColor(style.Global.BackgroundColor.Color())
127 | m.SetTextColor(style.Global.TextColor.Color())
128 | m.SetButtonBackgroundColor(style.Others.ButtonsBackgroundColor.Color())
129 | m.SetButtonTextColor(style.Others.ButtonsTextColor.Color())
130 | }
131 |
132 | func (v *ViewModal) SetStyle(style *config.Styles) {
133 | SetCommonStyle(v.ViewModal, style)
134 | }
135 |
136 | func (l *ListModal) SetStyle(style *config.Styles) {
137 | SetCommonStyle(l.ListModal, style)
138 | }
139 |
140 | func (f *FormModal) SetStyle(style *config.Styles) {
141 | SetCommonStyle(f.FormModal, style)
142 | SetCommonStyle(f.FormModal.Form, style)
143 |
144 | f.Form.SetButtonBackgroundColor(style.Others.ButtonsBackgroundColor.Color())
145 | f.Form.SetButtonTextColor(style.Others.ButtonsTextColor.Color())
146 | }
147 |
--------------------------------------------------------------------------------
/internal/tui/modal/inline_edit.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "context"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/tview"
9 | "github.com/kopecmaciej/vi-mongo/internal/manager"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
11 | "github.com/kopecmaciej/vi-mongo/internal/util"
12 | )
13 |
14 | const (
15 | InlineEditModalId = "InlineEditModal"
16 | )
17 |
18 | type InlineEditModal struct {
19 | *core.BaseElement
20 | *core.FormModal
21 |
22 | fieldName string
23 | originalValue string
24 | applyCallback func(fieldName, newValue string) error
25 | cancelCallback func()
26 | }
27 |
28 | func NewInlineEditModal() *InlineEditModal {
29 | iem := &InlineEditModal{
30 | BaseElement: core.NewBaseElement(),
31 | FormModal: core.NewFormModal(),
32 | }
33 |
34 | iem.SetIdentifier(InlineEditModalId)
35 | iem.SetAfterInitFunc(iem.init)
36 | return iem
37 | }
38 |
39 | func (iem *InlineEditModal) init() error {
40 | iem.setLayout()
41 | iem.setStyle()
42 | iem.setKeybindings()
43 | iem.handleEvents()
44 |
45 | return nil
46 | }
47 |
48 | func (iem *InlineEditModal) setLayout() {
49 | iem.SetTitle(" Inline Edit ")
50 | iem.SetBorder(true)
51 | iem.SetTitleAlign(tview.AlignCenter)
52 | iem.Form.SetBorderPadding(2, 2, 2, 2)
53 | }
54 |
55 | func (iem *InlineEditModal) setStyle() {
56 | styles := iem.App.GetStyles()
57 | iem.SetStyle(styles)
58 |
59 | iem.Form.SetFieldTextColor(styles.Connection.FormInputColor.Color())
60 | iem.Form.SetFieldBackgroundColor(styles.Connection.FormInputBackgroundColor.Color())
61 | iem.Form.SetLabelColor(styles.Connection.FormLabelColor.Color())
62 | }
63 |
64 | func (iem *InlineEditModal) setKeybindings() {
65 | iem.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
66 | switch event.Key() {
67 | case tcell.KeyEsc:
68 | if iem.cancelCallback != nil {
69 | iem.cancelCallback()
70 | }
71 | return nil
72 | case tcell.KeyEnter:
73 | formItemIndex, _ := iem.Form.GetFocusedItemIndex()
74 | if formItemIndex == 0 {
75 | iem.handleApply()
76 | return nil
77 | }
78 | }
79 |
80 | return event
81 | })
82 | }
83 |
84 | func (iem *InlineEditModal) handleEvents() {
85 | go iem.HandleEvents(iem.GetIdentifier(), func(event manager.EventMsg) {
86 | switch event.Message.Type {
87 | case manager.StyleChanged:
88 | iem.setStyle()
89 | }
90 | })
91 | }
92 |
93 | func (iem *InlineEditModal) SetApplyCallback(callback func(fieldName, newValue string) error) {
94 | iem.applyCallback = callback
95 | }
96 |
97 | func (iem *InlineEditModal) SetCancelCallback(callback func()) {
98 | iem.cancelCallback = callback
99 | }
100 |
101 | func (iem *InlineEditModal) handleApply() {
102 | if iem.Form.GetFormItemCount() == 0 {
103 | return
104 | }
105 |
106 | var newValue string
107 | formItem := iem.Form.GetFormItem(0)
108 |
109 | switch field := formItem.(type) {
110 | case *tview.InputField:
111 | newValue = field.GetText()
112 | case *tview.TextArea:
113 | newValue = field.GetText()
114 | default:
115 | return
116 | }
117 |
118 | if iem.applyCallback != nil {
119 | err := iem.applyCallback(iem.fieldName, newValue)
120 | if err != nil {
121 | ShowError(iem.App.Pages, "Error applying edit", err)
122 | return
123 | }
124 | }
125 | }
126 |
127 | func (iem *InlineEditModal) Render(ctx context.Context, fieldName, currentValue string) error {
128 | iem.Form.Clear(true)
129 | iem.fieldName = fieldName
130 | iem.originalValue = currentValue
131 |
132 | displayValue := util.CleanJsonWhitespaces(currentValue)
133 |
134 | if strings.Contains(displayValue, "\n") || len(displayValue) > 100 {
135 | textArea := tview.NewTextArea().
136 | SetText(displayValue, true).
137 | SetWrap(true).
138 | SetSize(8, 0)
139 |
140 | iem.Form.AddFormItem(textArea)
141 | } else {
142 | inputField := tview.NewInputField().
143 | SetText(displayValue).
144 | SetFieldWidth(0)
145 |
146 | iem.Form.AddFormItem(inputField)
147 | }
148 |
149 | iem.Form.AddButton("Apply", func() {
150 | iem.handleApply()
151 | })
152 |
153 | iem.Form.AddButton("Cancel", func() {
154 | if iem.cancelCallback != nil {
155 | iem.cancelCallback()
156 | }
157 | })
158 |
159 | iem.Show()
160 | return nil
161 | }
162 |
163 | func (iem *InlineEditModal) Show() {
164 | iem.App.Pages.AddPage(InlineEditModalId, iem, true, true)
165 | }
166 |
167 | func (iem *InlineEditModal) Hide() {
168 | iem.App.Pages.RemovePage(InlineEditModalId)
169 | }
170 |
--------------------------------------------------------------------------------
/internal/tui/component/peeker.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/kopecmaciej/vi-mongo/internal/manager"
7 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
8 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
9 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/primitives"
11 | "github.com/rs/zerolog/log"
12 |
13 | "github.com/atotto/clipboard"
14 | "github.com/gdamore/tcell/v2"
15 | "github.com/kopecmaciej/tview"
16 | )
17 |
18 | const (
19 | PeekerId = "Peeker"
20 | )
21 |
22 | // Peeker is a view that provides a modal view for peeking at a document
23 | type Peeker struct {
24 | *core.BaseElement
25 | *core.ViewModal
26 |
27 | docModifier *DocModifier
28 | currentDoc string
29 |
30 | doneFunc func()
31 | }
32 |
33 | // NewPeeker creates a new Peeker view
34 | func NewPeeker() *Peeker {
35 | p := &Peeker{
36 | BaseElement: core.NewBaseElement(),
37 | ViewModal: core.NewViewModal(),
38 | docModifier: NewDocModifier(),
39 | }
40 |
41 | p.SetIdentifier(PeekerId)
42 | p.SetAfterInitFunc(p.init)
43 |
44 | return p
45 | }
46 |
47 | func (p *Peeker) init() error {
48 | p.setStyle()
49 | p.setLayout()
50 | p.setKeybindings()
51 |
52 | if err := p.docModifier.Init(p.App); err != nil {
53 | return err
54 | }
55 |
56 | p.handleEvents()
57 |
58 | return nil
59 | }
60 |
61 | func (p *Peeker) handleEvents() {
62 | go p.HandleEvents(PeekerId, func(event manager.EventMsg) {
63 | switch event.Message.Type {
64 | case manager.StyleChanged:
65 | p.setStyle()
66 | }
67 | })
68 | }
69 |
70 | func (p *Peeker) setLayout() {
71 | p.SetBorder(true)
72 | p.SetTitle("Document Details")
73 | p.SetTitleAlign(tview.AlignLeft)
74 |
75 | p.ViewModal.AddButtons([]string{"Edit", "Close"})
76 | }
77 |
78 | func (p *Peeker) setStyle() {
79 | style := &p.App.GetStyles().DocPeeker
80 | p.ViewModal.SetStyle(p.App.GetStyles())
81 | p.SetHighlightColor(style.HighlightColor.Color())
82 | p.SetDocumentColors(
83 | style.KeyColor.Color(),
84 | style.ValueColor.Color(),
85 | style.BracketColor.Color(),
86 | )
87 | }
88 |
89 | func (p *Peeker) setKeybindings() {
90 | k := p.App.GetKeys()
91 | p.ViewModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
92 | switch {
93 | case k.Contains(k.Peeker.MoveToTop, event.Name()):
94 | p.MoveToTop()
95 | return nil
96 | case k.Contains(k.Peeker.MoveToBottom, event.Name()):
97 | p.MoveToBottom()
98 | return nil
99 | case k.Contains(k.Peeker.CopyHighlight, event.Name()):
100 | if err := p.ViewModal.CopySelectedLine(clipboard.WriteAll, "full"); err != nil {
101 | log.Error().Err(err).Msg("Error copying full line")
102 | modal.ShowError(p.App.Pages, "Error copying full line", err)
103 | }
104 | return nil
105 | case k.Contains(k.Peeker.CopyValue, event.Name()):
106 | if err := p.ViewModal.CopySelectedLine(clipboard.WriteAll, "value"); err != nil {
107 | log.Error().Err(err).Msg("Error copying value")
108 | modal.ShowError(p.App.Pages, "Error copying value", err)
109 | }
110 | return nil
111 | case k.Contains(k.Peeker.ToggleFullScreen, event.Name()):
112 | p.ViewModal.SetFullScreen(!p.ViewModal.IsFullScreen())
113 | p.ViewModal.MoveToTop()
114 | return nil
115 | case k.Contains(k.Peeker.Exit, event.Name()):
116 | p.App.Pages.RemovePage(p.GetIdentifier())
117 | return nil
118 | }
119 | return event
120 | })
121 | }
122 |
123 | func (p *Peeker) MoveToTop() {
124 | p.ViewModal.MoveToTop()
125 | }
126 |
127 | func (p *Peeker) MoveToBottom() {
128 | p.ViewModal.MoveToBottom()
129 | }
130 |
131 | func (p *Peeker) SetDoneFunc(doneFunc func()) {
132 | p.doneFunc = doneFunc
133 | }
134 |
135 | func (p *Peeker) Render(ctx context.Context, state *mongo.CollectionState, _id any) error {
136 | p.MoveToTop()
137 | doc, err := state.GetJsonDocById(_id)
138 | if err != nil {
139 | return err
140 | }
141 |
142 | p.currentDoc = doc
143 | p.setText()
144 |
145 | p.App.Pages.AddPage(p.GetIdentifier(), p.ViewModal, true, true)
146 | p.ViewModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
147 | if buttonLabel == "Edit" {
148 | updatedDoc, err := p.docModifier.Edit(ctx, state.Db, state.Coll, _id, p.currentDoc)
149 | if err != nil {
150 | modal.ShowError(p.App.Pages, "Error editing document", err)
151 | return
152 | }
153 |
154 | if updatedDoc != "" {
155 | state.UpdateRawDoc(updatedDoc)
156 | p.currentDoc = updatedDoc
157 | if p.doneFunc != nil {
158 | p.doneFunc()
159 | }
160 | p.setText()
161 | }
162 | } else if buttonLabel == "Close" || buttonLabel == "" {
163 | p.App.Pages.RemovePage(p.GetIdentifier())
164 | }
165 | })
166 | return nil
167 | }
168 |
169 | func (p *Peeker) setText() {
170 | p.ViewModal.SetText(primitives.Text{
171 | Content: p.currentDoc,
172 | Color: p.App.GetStyles().DocPeeker.ValueColor.Color(),
173 | Align: tview.AlignLeft,
174 | })
175 | }
176 |
--------------------------------------------------------------------------------
/internal/tui/modal/history.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "os"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/vi-mongo/internal/config"
9 | "github.com/kopecmaciej/vi-mongo/internal/manager"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
11 | "github.com/kopecmaciej/vi-mongo/internal/tui/primitives"
12 | "github.com/kopecmaciej/vi-mongo/internal/util"
13 | )
14 |
15 | const (
16 | HistoryModalId = "History"
17 | QueryBarId = "QueryBar"
18 |
19 | maxHistory = 10
20 | )
21 |
22 | // History is a modal with history of queries
23 | type History struct {
24 | *core.BaseElement
25 | *primitives.ListModal
26 |
27 | style *config.HistoryStyle
28 | }
29 |
30 | func NewHistoryModal() *History {
31 | h := &History{
32 | BaseElement: core.NewBaseElement(),
33 | ListModal: primitives.NewListModal(),
34 | }
35 |
36 | h.SetIdentifier(HistoryModalId)
37 | h.SetAfterInitFunc(h.init)
38 |
39 | return h
40 | }
41 |
42 | // Init initializes HistoryModal
43 | func (h *History) init() error {
44 | h.SetLayout()
45 | h.setStyle()
46 | h.setKeybindings()
47 |
48 | return nil
49 | }
50 |
51 | func (h *History) SetLayout() {
52 | h.SetTitle(" History ")
53 | h.SetBorder(true)
54 | h.ShowSecondaryText(false)
55 | h.ListModal.SetBorderPadding(1, 1, 2, 2)
56 | }
57 |
58 | func (h *History) setStyle() {
59 | h.style = &h.App.GetStyles().History
60 | globalBackground := h.App.GetStyles().Global.BackgroundColor.Color()
61 |
62 | mainStyle := tcell.StyleDefault.
63 | Foreground(h.style.TextColor.Color()).
64 | Background(globalBackground)
65 | h.SetMainTextStyle(mainStyle)
66 |
67 | selectedStyle := tcell.StyleDefault.
68 | Foreground(h.style.SelectedTextColor.Color()).
69 | Background(h.style.SelectedBackgroundColor.Color())
70 | h.SetSelectedStyle(selectedStyle)
71 | }
72 |
73 | func (h *History) setKeybindings() {
74 | keys := h.App.GetKeys()
75 | h.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
76 | switch {
77 | case keys.Contains(keys.History.AcceptEntry, event.Name()):
78 | return h.sendEventAndClose(event)
79 | case keys.Contains(keys.History.CloseHistory, event.Name()):
80 | return h.sendEventAndClose(event)
81 | case keys.Contains(keys.History.ClearHistory, event.Name()):
82 | return h.clearHistory()
83 | }
84 | return event
85 | })
86 | }
87 |
88 | func (h *History) sendEventAndClose(event *tcell.EventKey) *tcell.EventKey {
89 | eventKey := manager.EventMsg{EventKey: event, Sender: h.GetIdentifier()}
90 | h.SendToElement(QueryBarId, eventKey)
91 | h.App.Pages.RemovePage(h.GetIdentifier())
92 |
93 | return nil
94 | }
95 |
96 | func (h *History) clearHistory() *tcell.EventKey {
97 | err := os.WriteFile(getHisotryFilePath(), []byte{}, 0644)
98 | if err != nil {
99 | ShowError(h.App.Pages, "Failed to clear history", err)
100 | }
101 | h.App.Pages.RemovePage(h.GetIdentifier())
102 | ShowInfo(h.App.Pages, "History cleared")
103 |
104 | return nil
105 | }
106 |
107 | // Render loads history from file and renders it
108 | func (h *History) Render() {
109 | h.Clear()
110 |
111 | history, err := h.loadHistory()
112 | if err != nil {
113 | ShowError(h.App.Pages, "Failed to load history", err)
114 | return
115 | }
116 |
117 | // load in reverse order
118 | for i := len(history) - 1; i >= 0; i-- {
119 | rune := 57 - i
120 | entry := history[i]
121 | h.AddItem(entry, "", int32(rune), nil)
122 | }
123 |
124 | h.App.Pages.AddPage(h.GetIdentifier(), h, true, true)
125 | }
126 |
127 | // SaveToHistory saves text to history file, if it's not already there.
128 | // It will overwrite oldest entry if history is full.
129 | func (h *History) SaveToHistory(text string) error {
130 | history, err := h.loadHistory()
131 | if err != nil {
132 | return err
133 | }
134 |
135 | var updatedHistory []string
136 | for _, line := range history {
137 | if line != text {
138 | updatedHistory = append(updatedHistory, line)
139 | if len(updatedHistory) >= maxHistory {
140 | updatedHistory = updatedHistory[1:]
141 | }
142 | }
143 | }
144 | updatedHistory = append(updatedHistory, text)
145 |
146 | historyFile, err := os.OpenFile(getHisotryFilePath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
147 | if err != nil {
148 | return err
149 | }
150 | defer historyFile.Close()
151 |
152 | for _, entry := range updatedHistory {
153 | _, err = historyFile.WriteString(entry + "\n")
154 | if err != nil {
155 | return err
156 | }
157 | }
158 |
159 | return nil
160 | }
161 |
162 | // GetText returns text from selected item
163 | func (h *History) GetText() string {
164 | text := h.ListModal.GetText()
165 |
166 | return strings.TrimSpace(text)
167 | }
168 |
169 | // loadHistory loads history from history file
170 | func (h *History) loadHistory() ([]string, error) {
171 | bytes, err := os.ReadFile(getHisotryFilePath())
172 | if err != nil {
173 | if os.IsNotExist(err) {
174 | err = os.WriteFile(getHisotryFilePath(), []byte{}, 0644)
175 | }
176 | return nil, err
177 | }
178 |
179 | history := []string{}
180 | lines := strings.Split(string(bytes), "\n")
181 |
182 | for _, line := range lines {
183 | if line != "" {
184 | history = append(history, line)
185 | }
186 | }
187 |
188 | return history, nil
189 | }
190 |
191 | func getHisotryFilePath() string {
192 | configDir, err := util.GetConfigDir()
193 | if err != nil {
194 | return ""
195 | }
196 |
197 | return configDir + "/history.txt"
198 | }
199 |
--------------------------------------------------------------------------------
/internal/tui/component/header.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/kopecmaciej/tview"
8 | "github.com/kopecmaciej/vi-mongo/internal/config"
9 | "github.com/kopecmaciej/vi-mongo/internal/manager"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
11 | )
12 |
13 | const (
14 | HeaderId = "Header"
15 | )
16 |
17 | type (
18 | order int
19 |
20 | info struct {
21 | label string
22 | value string
23 | }
24 |
25 | BaseInfo map[order]info
26 |
27 | // Header is a view that displays basic information and keybindings in the header
28 | Header struct {
29 | *core.BaseElement
30 | *core.Table
31 |
32 | style *config.HeaderStyle
33 | baseInfo BaseInfo
34 | keys []config.Key
35 | currentFocus tview.Identifier
36 | }
37 | )
38 |
39 | // NewHeader creates a new header view
40 | func NewHeader() *Header {
41 | h := Header{
42 | BaseElement: core.NewBaseElement(),
43 | Table: core.NewTable(),
44 | baseInfo: make(BaseInfo),
45 | }
46 |
47 | h.SetIdentifier(HeaderId)
48 | h.SetAfterInitFunc(h.init)
49 |
50 | return &h
51 | }
52 |
53 | func (h *Header) init() error {
54 | h.setStyle()
55 | h.setLayout()
56 |
57 | h.handleEvents()
58 |
59 | return nil
60 | }
61 |
62 | func (h *Header) setLayout() {
63 | h.Table.SetBorder(true)
64 | h.Table.SetTitle(" Basic Info ")
65 | h.Table.SetBorderPadding(0, 0, 1, 1)
66 | }
67 |
68 | func (h *Header) setStyle() {
69 | h.style = &h.App.GetStyles().Header
70 | h.SetStyle(h.App.GetStyles())
71 | }
72 |
73 | // SetBaseInfo sets the basic information about the database connection
74 | func (h *Header) SetBaseInfo() BaseInfo {
75 | h.baseInfo = BaseInfo{
76 | 0: {"Status", h.style.ActiveSymbol.String()},
77 | 1: {"Host", h.Dao.Config.Host},
78 | }
79 | return h.baseInfo
80 | }
81 |
82 | // Render renders the header view
83 | func (h *Header) Render() {
84 | h.Table.Clear()
85 | base := h.SetBaseInfo()
86 |
87 | maxInRow := 2
88 | currCol := 0
89 | currRow := 0
90 |
91 | for i := 0; i < len(base); i++ {
92 | if i%maxInRow == 0 && i != 0 {
93 | currCol += 2
94 | currRow = 0
95 | }
96 | order := order(i)
97 | h.Table.SetCell(currRow, currCol, h.keyCell(base[order].label))
98 | h.Table.SetCell(currRow, currCol+1, h.valueCell(base[order].value))
99 | currRow++
100 | }
101 |
102 | h.Table.SetCell(0, 2, tview.NewTableCell(" "))
103 | h.Table.SetCell(1, 2, tview.NewTableCell(" "))
104 | currCol++
105 |
106 | k, err := h.UpdateKeys()
107 | if err != nil {
108 | currCol += 2
109 | h.Table.SetCell(0, currCol, h.keyCell("No special keys for this element"))
110 | h.Table.SetCell(1, currCol, h.valueCell("Press "+"<"+h.App.GetKeys().Global.ToggleFullScreenHelp.String()+">"+" to see available keybindings"))
111 | return
112 | }
113 |
114 | for _, key := range k {
115 | if currRow%maxInRow == 0 && currRow != 0 {
116 | currCol += 2
117 | currRow = 0
118 | }
119 | var keyString string
120 | var iter []string
121 | // keys can be both runes and keys
122 | if len(key.Keys) > 0 {
123 | iter = append(iter, key.Keys...)
124 | }
125 | if len(key.Runes) > 0 {
126 | iter = append(iter, key.Runes...)
127 | }
128 | for i, k := range iter {
129 | if i == 0 {
130 | keyString = k
131 | } else {
132 | keyString = fmt.Sprintf("%s, %s", keyString, k)
133 | }
134 | }
135 |
136 | h.Table.SetCell(currRow, currCol, h.keyCell(keyString))
137 | h.Table.SetCell(currRow, currCol+1, h.valueCell(key.Description))
138 | currRow++
139 | }
140 | }
141 |
142 | func (h *Header) setInactiveBaseInfo(err error) {
143 | h.baseInfo = make(BaseInfo)
144 | h.baseInfo[0] = info{"Status", h.style.InactiveSymbol.String()}
145 | if err != nil {
146 | if strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
147 | h.baseInfo[1] = info{"Error", "Unauthorized, please check your credentials or your privileges"}
148 | } else {
149 | h.baseInfo[1] = info{"Error", err.Error()}
150 | }
151 | }
152 | }
153 |
154 | // handle events from the manager
155 | func (h *Header) handleEvents() {
156 | go h.HandleEvents(HeaderId, func(event manager.EventMsg) {
157 | switch event.Message.Type {
158 | case manager.FocusChanged:
159 | h.currentFocus = tview.Identifier(event.Message.Data.(tview.Identifier))
160 | go h.App.QueueUpdateDraw(func() {
161 | h.Render()
162 | })
163 | case manager.StyleChanged:
164 | h.setStyle()
165 | go h.App.QueueUpdateDraw(func() {
166 | h.Render()
167 | })
168 | }
169 | })
170 | }
171 |
172 | func (h *Header) keyCell(text string) *tview.TableCell {
173 | cell := tview.NewTableCell(text + " ")
174 | cell.SetTextColor(h.style.KeyColor.Color())
175 |
176 | return cell
177 | }
178 |
179 | func (h *Header) valueCell(text string) *tview.TableCell {
180 | cell := tview.NewTableCell(text)
181 | cell.SetTextColor(h.style.ValueColor.Color())
182 |
183 | return cell
184 | }
185 |
186 | // UpdateKeys updates the keybindings for the current focused element
187 | func (h *Header) UpdateKeys() ([]config.Key, error) {
188 | if h.currentFocus == "" {
189 | return nil, nil
190 | }
191 |
192 | // hack for DatabaseTree, as it's child of Database
193 | // TODO: think of better solution for this
194 | if h.currentFocus == "DatabaseTree" {
195 | h.currentFocus = "Database"
196 | }
197 |
198 | orderedKeys, err := h.App.GetKeys().GetKeysForElement(string(h.currentFocus))
199 | if err != nil {
200 | return nil, err
201 | }
202 | keys := orderedKeys[0].Keys
203 |
204 | if len(keys) > 0 {
205 | h.keys = keys
206 | } else {
207 | h.keys = nil
208 | }
209 |
210 | return keys, nil
211 | }
212 |
--------------------------------------------------------------------------------
/internal/tui/page/help.go:
--------------------------------------------------------------------------------
1 | package page
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/tview"
9 | "github.com/kopecmaciej/vi-mongo/internal/config"
10 | "github.com/kopecmaciej/vi-mongo/internal/manager"
11 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
12 | )
13 |
14 | const (
15 | HelpPageId = "Help"
16 | )
17 |
18 | // Help is a view that provides a help screen for keybindings
19 | type Help struct {
20 | *core.BaseElement
21 | *core.Table
22 |
23 | style *config.HelpStyle
24 |
25 | keyWidth, descWidth int
26 | }
27 |
28 | // NewHelp creates a new Help view
29 | func NewHelp() *Help {
30 | h := &Help{
31 | BaseElement: core.NewBaseElement(),
32 | Table: core.NewTable(),
33 | }
34 |
35 | h.SetIdentifier(HelpPageId)
36 | h.SetAfterInitFunc(h.init)
37 |
38 | return h
39 | }
40 |
41 | func (h *Help) init() error {
42 | h.setLayout()
43 | h.setStyle()
44 | h.setKeybindings()
45 |
46 | h.handleEvents()
47 |
48 | return nil
49 | }
50 |
51 | func (h *Help) handleEvents() {
52 | go h.HandleEvents(HelpPageId, func(event manager.EventMsg) {
53 | switch event.Message.Type {
54 | case manager.StyleChanged:
55 | h.setStyle()
56 | go h.App.QueueUpdateDraw(func() {
57 | h.Render()
58 | })
59 | }
60 | })
61 | }
62 |
63 | func (h *Help) Render() {
64 | h.Table.Clear()
65 |
66 | allKeys := h.App.GetKeys().GetAvaliableKeys()
67 | if h.keyWidth == 0 || h.descWidth == 0 {
68 | h.keyWidth, h.descWidth = h.calculateMaxWidth(allKeys)
69 | }
70 |
71 | secondRowElements := []config.OrderedKeys{}
72 | thirdRowElements := []config.OrderedKeys{}
73 | row := 0
74 | col := 0
75 | for _, viewKeys := range allKeys {
76 | if viewKeys.Element == "Global" || viewKeys.Element == "Help" {
77 | thirdRowElements = append(thirdRowElements, viewKeys)
78 | } else if viewKeys.Element == "Welcome" || viewKeys.Element == "Connection" {
79 | secondRowElements = append(secondRowElements, viewKeys)
80 | } else {
81 | h.renderKeySection([]config.OrderedKeys{viewKeys}, &row, col)
82 | }
83 | }
84 |
85 | row = 0
86 | col = 2
87 | for _, viewKeys := range secondRowElements {
88 | h.renderKeySection([]config.OrderedKeys{viewKeys}, &row, col)
89 | }
90 |
91 | row = 0
92 | col = 4
93 | for _, viewKeys := range thirdRowElements {
94 | h.renderKeySection([]config.OrderedKeys{viewKeys}, &row, col)
95 | }
96 |
97 | h.Table.ScrollToBeginning()
98 | }
99 |
100 | // calculateMaxWidth calculates the maximum width of the row
101 | func (h *Help) calculateMaxWidth(keys []config.OrderedKeys) (int, int) {
102 | keyWidth, descWidth := 0, 0
103 | for _, viewKeys := range keys {
104 | for _, key := range viewKeys.Keys {
105 | if len(key.Keys) > 0 {
106 | keyWidth = len(key.Keys)
107 | } else {
108 | keyWidth = len(key.Runes)
109 | }
110 |
111 | if len(key.Description) > descWidth {
112 | descWidth = len(key.Description)
113 | }
114 | }
115 | }
116 | return keyWidth, descWidth
117 | }
118 |
119 | // Add this new method to render key sections
120 | func (h *Help) renderKeySection(keys []config.OrderedKeys, row *int, col int) {
121 | for _, viewKeys := range keys {
122 | viewName := viewKeys.Element
123 | if viewName == "Main" {
124 | viewName = "Main Layout"
125 | }
126 | h.addHeaderSection(viewName, *row, col)
127 | *row += 2
128 | h.AddKeySection(viewName, viewKeys.Keys, row, col)
129 | *row++
130 | }
131 | }
132 |
133 | func (h *Help) addHeaderSection(name string, row, col int) {
134 | h.Table.SetCell(row+0, col, tview.NewTableCell(name).SetTextColor(h.style.HeaderColor.Color()))
135 | h.Table.SetCell(row+1, col, tview.NewTableCell("-------").SetTextColor(h.style.DescriptionColor.Color()))
136 | // let's fill blank cells with empty strings
137 | h.Table.SetCell(row+0, col+1, tview.NewTableCell("").SetTextColor(h.style.HeaderColor.Color()))
138 | h.Table.SetCell(row+1, col+1, tview.NewTableCell("").SetTextColor(h.style.DescriptionColor.Color()))
139 | }
140 |
141 | func (h *Help) AddKeySection(name string, keys []config.Key, row *int, col int) {
142 | for _, key := range keys {
143 | var keyString string
144 |
145 | if len(key.Keys) > 0 && len(key.Runes) > 0 {
146 | keyString = fmt.Sprintf("%s, %s",
147 | strings.Join(key.Keys, ", "),
148 | strings.Join(key.Runes, ", "))
149 | } else if len(key.Keys) > 0 {
150 | keyString = strings.Join(key.Keys, ", ")
151 | } else if len(key.Runes) > 0 {
152 | keyString = strings.Join(key.Runes, ", ")
153 | }
154 |
155 | h.Table.SetCell(*row, col, tview.NewTableCell(keyString).SetTextColor(h.style.KeyColor.Color()))
156 | h.Table.SetCell(*row, col+1, tview.NewTableCell(key.Description).SetTextColor(h.style.DescriptionColor.Color()))
157 | *row++
158 | // remove blank cells
159 | h.Table.SetCell(*row, col, tview.NewTableCell(""))
160 | h.Table.SetCell(*row, col+1, tview.NewTableCell(""))
161 | }
162 | }
163 |
164 | func (h *Help) setStyle() {
165 | h.style = &h.App.GetStyles().Help
166 | h.SetStyle(h.App.GetStyles())
167 | }
168 |
169 | func (h *Help) setLayout() {
170 | h.Table.SetBorder(true)
171 | h.Table.SetTitle(" Help ")
172 | h.Table.SetBorderPadding(1, 1, 3, 3)
173 | h.Table.SetSelectable(false, false)
174 | h.Table.SetTitleAlign(tview.AlignLeft)
175 | h.Table.SetEvaluateAllRows(true)
176 | }
177 |
178 | func (h *Help) setKeybindings() {
179 | k := h.App.GetKeys()
180 |
181 | h.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
182 | switch {
183 | case k.Contains(k.Help.Close, event.Name()):
184 | h.App.Pages.RemovePage(HelpPageId)
185 | return nil
186 | }
187 | return event
188 | })
189 | }
190 |
--------------------------------------------------------------------------------
/internal/tui/primitives/view_modal_test.go:
--------------------------------------------------------------------------------
1 | package primitives
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/gdamore/tcell/v2"
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestCalculateNextLinesToHighlight(t *testing.T) {
11 | m := NewViewModal()
12 | tests := []struct {
13 | name string
14 | lines []string
15 | selectedLine int
16 | nextLinesToHighlight int
17 | }{
18 | {
19 | name: "two line value",
20 | lines: []string{
21 | "{",
22 | ` "key": "value",`,
23 | ` "key2": "value2`,
24 | `verylongvaluethatdoesnotfitoneline`,
25 | ` "key3": "value3",`,
26 | "}",
27 | },
28 | selectedLine: 2,
29 | nextLinesToHighlight: 1,
30 | },
31 | {
32 | name: "Simple object",
33 | lines: []string{
34 | "{",
35 | ` "key": "value",`,
36 | ` "object": {`,
37 | ` "nested": "value"`,
38 | ` }`,
39 | "}",
40 | },
41 | selectedLine: 2,
42 | nextLinesToHighlight: 2,
43 | },
44 | {
45 | name: "Nested object",
46 | lines: []string{
47 | "{",
48 | ` "object": {`,
49 | ` "nested1": "value1",`,
50 | ` "nested2": {`,
51 | ` "nested3": "value3"`,
52 | ` }`,
53 | ` },`,
54 | ` "key": "value"`,
55 | "}",
56 | },
57 | selectedLine: 1,
58 | nextLinesToHighlight: 5,
59 | },
60 | {
61 | name: "Array",
62 | lines: []string{
63 | "{",
64 | ` "array": [`,
65 | ` "item1",`,
66 | ` "item2",`,
67 | ` "item3"`,
68 | ` ]`,
69 | `}`,
70 | },
71 | selectedLine: 1,
72 | nextLinesToHighlight: 4,
73 | },
74 | {
75 | name: "Array with nested object",
76 | lines: []string{
77 | "{",
78 | ` "array_nested": [`,
79 | ` {`,
80 | ` "nested_key": "value"`,
81 | ` }`,
82 | ` ]`,
83 | `}`,
84 | },
85 | selectedLine: 2,
86 | nextLinesToHighlight: 2,
87 | },
88 | }
89 |
90 | for _, tt := range tests {
91 | t.Run(tt.name, func(t *testing.T) {
92 | m.selectedLine = tt.selectedLine
93 | m.scrollPosition = 0
94 | result := m.calculateNextLinesToHighlight(tt.lines)
95 | assert.Equal(t, tt.nextLinesToHighlight, result)
96 | })
97 | }
98 | }
99 |
100 | func TestFormatLine(t *testing.T) {
101 | m := NewViewModal()
102 | m.SetDocumentColors(tcell.ColorRed, tcell.ColorGreen, tcell.ColorBlue)
103 |
104 | tests := []struct {
105 | name string
106 | input string
107 | isFirst bool
108 | expected string
109 | }{
110 | {
111 | name: "First line with bracket",
112 | input: "{",
113 | isFirst: true,
114 | expected: "[#0000FF]{[#008000]",
115 | },
116 | {
117 | name: "Key-value pair",
118 | input: ` "key": "value",`,
119 | isFirst: false,
120 | expected: ` [#FF0000]"key"[:]: [#008000]"value",[-]`,
121 | },
122 | {
123 | name: "Nested object",
124 | input: ` "object": {`,
125 | isFirst: false,
126 | expected: ` [#FF0000]"object"[:]: [#008000][#0000FF]{[-]`,
127 | },
128 | }
129 |
130 | for _, tt := range tests {
131 | t.Run(tt.name, func(t *testing.T) {
132 | result := m.formatAndColorizeLine(tt.input, tt.isFirst)
133 | assert.Equal(t, tt.expected, result)
134 | })
135 | }
136 | }
137 |
138 | func TestHighlightLine(t *testing.T) {
139 | m := NewViewModal()
140 | m.SetHighlightColor(tcell.ColorYellow)
141 |
142 | tests := []struct {
143 | name string
144 | input string
145 | withMark bool
146 | expected string
147 | }{
148 | {
149 | name: "With mark",
150 | input: "Test line",
151 | withMark: true,
152 | expected: "[-:#FFFF00:b]>Test line[-:-:-]",
153 | },
154 | {
155 | name: "Without mark",
156 | input: "Another test",
157 | withMark: false,
158 | expected: "[-:#FFFF00:b]Another test[-:-:-]",
159 | },
160 | }
161 |
162 | for _, tt := range tests {
163 | t.Run(tt.name, func(t *testing.T) {
164 | result := m.highlightLine(tt.input, tt.withMark)
165 | assert.Equal(t, tt.expected, result)
166 | })
167 | }
168 | }
169 |
170 | func TestCopySelectedLine(t *testing.T) {
171 | m := NewViewModal()
172 | m.SetRect(0, 0, 50, 10) // Set a fixed size for testing
173 |
174 | tests := []struct {
175 | name string
176 | content string
177 | copyType string
178 | expected string
179 | }{
180 | {
181 | name: "Copy full single line",
182 | content: `{
183 | "key": "value",
184 | "object": {
185 | "nested": "test"
186 | }
187 | }`,
188 | copyType: "full",
189 | expected: `"key": "value"`,
190 | },
191 | {
192 | name: "Copy full multiline line",
193 | content: `{
194 | "object": {
195 | "nested": "test_first_line_value_
196 | test_second_line_value_in_new_line"
197 | }
198 | }`,
199 | copyType: "full",
200 | expected: `"object": { "nested": "test_first_line_value_test_second_line_value_in_new_line" }`,
201 | },
202 | {
203 | name: "Copy value only",
204 | content: `{
205 | "key": "value",
206 | "object": {
207 | "nested": "test"
208 | }
209 | }`,
210 | copyType: "value",
211 | expected: `"value"`,
212 | },
213 | {
214 | name: "Copy value that's object",
215 | content: `{
216 | "object": {
217 | "nested": "test"
218 | }
219 | }`,
220 | copyType: "value",
221 | expected: `{ "nested": "test" }`,
222 | },
223 | }
224 |
225 | for _, tt := range tests {
226 | t.Run(tt.name, func(t *testing.T) {
227 | m.SetText(Text{Content: tt.content})
228 | m.selectedLine = 1
229 | m.scrollPosition = 0
230 |
231 | var copiedText string
232 | copyFunc := func(text string) error {
233 | copiedText = text
234 | return nil
235 | }
236 |
237 | err := m.CopySelectedLine(copyFunc, tt.copyType)
238 | assert.NoError(t, err)
239 | assert.Equal(t, tt.expected, copiedText)
240 | })
241 | }
242 | }
243 |
--------------------------------------------------------------------------------
/internal/tui/page/welcome.go:
--------------------------------------------------------------------------------
1 | package page
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/kopecmaciej/tview"
8 | "github.com/kopecmaciej/vi-mongo/internal/config"
9 | "github.com/kopecmaciej/vi-mongo/internal/manager"
10 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
11 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
12 | )
13 |
14 | const (
15 | WelcomePageId = "Welcome"
16 | )
17 |
18 | type Welcome struct {
19 | *core.BaseElement
20 | *core.Flex
21 |
22 | // Form
23 | form *core.Form
24 |
25 | style *config.WelcomeStyle
26 |
27 | // Callbacks
28 | onSubmit func()
29 | }
30 |
31 | func NewWelcome() *Welcome {
32 | w := &Welcome{
33 | BaseElement: core.NewBaseElement(),
34 | Flex: core.NewFlex(),
35 | form: core.NewForm(),
36 | }
37 |
38 | w.SetIdentifier(WelcomePageId)
39 |
40 | return w
41 | }
42 |
43 | func (w *Welcome) Init(app *core.App) error {
44 | w.App = app
45 |
46 | w.setLayout()
47 | w.setStyle()
48 |
49 | w.handleEvents()
50 |
51 | return nil
52 | }
53 |
54 | func (w *Welcome) setLayout() {
55 | w.form.SetBorder(true)
56 | w.form.SetTitle(" Welcome to Vi Mongo ")
57 | w.form.SetTitleAlign(tview.AlignCenter)
58 | w.form.SetButtonsAlign(tview.AlignCenter)
59 |
60 | w.form.AddButton("Save and Connect", func() {
61 | err := w.saveConfig()
62 | if err != nil {
63 | modal.ShowError(w.App.Pages, "Error while saving config", err)
64 | return
65 | }
66 | if w.onSubmit != nil {
67 | w.onSubmit()
68 | }
69 | })
70 |
71 | w.form.AddButton("Exit", func() {
72 | w.App.Stop()
73 | })
74 | }
75 |
76 | func (w *Welcome) setStyle() {
77 | w.style = &w.App.GetStyles().Welcome
78 | w.Flex.SetStyle(w.App.GetStyles())
79 | w.form.SetStyle(w.App.GetStyles())
80 |
81 | w.form.SetFieldTextColor(w.style.FormInputColor.Color())
82 | w.form.SetFieldBackgroundColor(w.style.FormInputBackgroundColor.Color())
83 | w.form.SetLabelColor(w.style.FormLabelColor.Color())
84 | }
85 |
86 | func (w *Welcome) handleEvents() {
87 | go w.HandleEvents(WelcomePageId, func(event manager.EventMsg) {
88 | switch event.Message.Type {
89 | case manager.StyleChanged:
90 | w.setStyle()
91 | go w.App.QueueUpdateDraw(func() {
92 | w.Render()
93 | })
94 | }
95 | })
96 | }
97 | func (w *Welcome) Render() {
98 | w.Clear()
99 |
100 | // easy way to center the form
101 | w.AddItem(tview.NewBox(), 0, 1, false)
102 |
103 | w.renderForm()
104 | w.AddItem(w.form, 0, 3, true)
105 |
106 | w.AddItem(tview.NewBox(), 0, 1, false)
107 |
108 | if page, _ := w.App.Pages.GetFrontPage(); page == WelcomePageId {
109 | w.App.SetFocus(w)
110 | }
111 | }
112 |
113 | func (w *Welcome) SetOnSubmitFunc(onSubmit func()) {
114 | w.onSubmit = onSubmit
115 | }
116 |
117 | func (w *Welcome) renderForm() {
118 | w.form.Clear(false)
119 |
120 | cfg := w.App.GetConfig()
121 |
122 | configFile, err := cfg.GetCurrentConfigPath()
123 | if err != nil {
124 | modal.ShowError(w.App.Pages, "Error while getting config path", err)
125 | return
126 | }
127 |
128 | welcomeText := "All configuration can be set in " + configFile + " file. You can also set it here."
129 | w.form.AddTextView("Welcome info", welcomeText, 0, 2, true, false)
130 | w.form.AddTextView(" ", "----------------------------------------------------------", 0, 1, true, false)
131 | w.form.AddTextView("Editor", "Set command (vim, nano etc) or env ($ENV)", 0, 1, true, false)
132 | editorCmd, err := cfg.GetEditorCmd()
133 | if err != nil {
134 | editorCmd = ""
135 | }
136 | w.form.AddInputField("Set editor", editorCmd, 30, nil, nil)
137 | w.form.AddTextView("Logs", "Requires restart if changed", 0, 1, true, false)
138 | w.form.AddInputField("Log File", cfg.Log.Path, 30, nil, nil)
139 | logLevels := []string{"debug", "info", "warn", "error", "fatal", "panic"}
140 | w.form.AddDropDown("Log Level", logLevels, getLogLevelIndex(cfg.Log.Level, logLevels), nil)
141 | w.form.AddCheckbox("Use symbols 🗁 🖿 🗎", cfg.Styles.BetterSymbols, nil)
142 | w.form.AddTextView("Show on start", "Set pages to show on every start", 60, 1, true, false)
143 | w.form.AddCheckbox("Connection page", cfg.ShowConnectionPage, nil)
144 | w.form.AddCheckbox("Welcome page", cfg.ShowWelcomePage, nil)
145 | w.form.AddTextView("Show help", fmt.Sprintf("Press %s to show key help", w.App.GetKeys().Global.ToggleFullScreenHelp.String()), 60, 1, true, false)
146 | w.form.AddTextView("Motions", "Use basic vim motions or normal arrow keys to move around", 60, 2, true, false)
147 | }
148 |
149 | func (w *Welcome) saveConfig() error {
150 | editorCmd := w.form.GetFormItemByLabel("Set editor").(*tview.InputField).GetText()
151 | logFile := w.form.GetFormItemByLabel("Log File").(*tview.InputField).GetText()
152 | // Get the selected log level from the dropdown
153 | _, logLevel := w.form.GetFormItemByLabel("Log Level").(*tview.DropDown).GetCurrentOption()
154 |
155 | c := w.App.GetConfig()
156 |
157 | splitedEditorCmd := strings.Split(editorCmd, "$")
158 | if len(splitedEditorCmd) > 1 {
159 | c.Editor.Command = ""
160 | c.Editor.Env = splitedEditorCmd[1]
161 | } else {
162 | c.Editor.Env = ""
163 | c.Editor.Command = editorCmd
164 | }
165 | c.Log.Path = logFile
166 | c.Log.Level = logLevel
167 | c.ShowConnectionPage = w.form.GetFormItemByLabel("Connection page").(*tview.Checkbox).IsChecked()
168 | c.ShowWelcomePage = w.form.GetFormItemByLabel("Welcome page").(*tview.Checkbox).IsChecked()
169 |
170 | betterSymbols := w.form.GetFormItemByLabel("Use symbols 🗁 🖿 🗎").(*tview.Checkbox).IsChecked()
171 | if betterSymbols != c.Styles.BetterSymbols {
172 | c.Styles.BetterSymbols = betterSymbols
173 | w.App.SetStyle(c.Styles.CurrentStyle)
174 | }
175 |
176 | err := w.App.GetConfig().UpdateConfig()
177 | if err != nil {
178 | return err
179 | }
180 |
181 | return nil
182 | }
183 |
184 | // Add this helper function at the end of the file
185 | func getLogLevelIndex(currentLevel string, levels []string) int {
186 | for i, level := range levels {
187 | if level == currentLevel {
188 | return i
189 | }
190 | }
191 | return 0
192 | }
193 |
--------------------------------------------------------------------------------
/internal/tui/component/database.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "regexp"
7 | "slices"
8 | "sync"
9 |
10 | "github.com/gdamore/tcell/v2"
11 | "github.com/kopecmaciej/tview"
12 | "github.com/kopecmaciej/vi-mongo/internal/manager"
13 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
14 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
15 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
16 | )
17 |
18 | const (
19 | DatabaseId = "Database"
20 | FilterBarId = "FilterBar"
21 | )
22 |
23 | // Database is flex container for DatabaseTree and InputBar
24 | type Database struct {
25 | *core.BaseElement
26 | *core.Flex
27 |
28 | DbTree *DatabaseTree
29 | filterBar *InputBar
30 | mutex sync.Mutex
31 | dbsWithColls []mongo.DBsWithCollections
32 | }
33 |
34 | func NewDatabase() *Database {
35 | d := &Database{
36 | BaseElement: core.NewBaseElement(),
37 | Flex: core.NewFlex(),
38 | DbTree: NewDatabaseTree(),
39 | filterBar: NewInputBar(FilterBarId, "Filter"),
40 | mutex: sync.Mutex{},
41 | }
42 |
43 | d.SetIdentifier(DatabaseId)
44 | d.SetAfterInitFunc(d.init)
45 |
46 | return d
47 | }
48 |
49 | func (d *Database) init() error {
50 | ctx := context.Background()
51 | d.setStyle()
52 | d.setKeybindings()
53 |
54 | if err := d.DbTree.Init(d.App); err != nil {
55 | return err
56 | }
57 |
58 | if err := d.filterBar.Init(d.App); err != nil {
59 | return err
60 | }
61 | d.filterBarHandler(ctx)
62 |
63 | d.handleEvents()
64 |
65 | return nil
66 | }
67 |
68 | func (d *Database) setStyle() {
69 | d.Flex.SetStyle(d.App.GetStyles())
70 | d.DbTree.SetStyle(d.App.GetStyles())
71 | d.filterBar.SetStyle(d.App.GetStyles())
72 | d.Flex.SetDirection(tview.FlexRow)
73 | }
74 |
75 | func (d *Database) setKeybindings() {
76 | keys := d.App.GetKeys()
77 | d.Flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
78 | switch {
79 | case keys.Contains(keys.Database.FilterBar, event.Name()):
80 | d.filterBar.Enable()
81 | d.Render()
82 | return nil
83 | }
84 | return event
85 | })
86 | }
87 |
88 | func (d *Database) handleEvents() {
89 | go d.HandleEvents(DatabaseId, func(event manager.EventMsg) {
90 | switch event.Message.Type {
91 | case manager.StyleChanged:
92 | d.setStyle()
93 | d.DbTree.RefreshStyle()
94 | }
95 | })
96 | }
97 |
98 | func (d *Database) Render() {
99 | ctx := context.Background()
100 | d.Flex.Clear()
101 |
102 | var primitive tview.Primitive
103 | primitive = d.DbTree
104 |
105 | if d.filterBar.IsEnabled() {
106 | d.Flex.AddItem(d.filterBar, 3, 0, false)
107 | primitive = d.filterBar
108 | }
109 | defer d.App.SetFocus(primitive)
110 |
111 | if err := d.listDbsAndCollections(ctx); err != nil {
112 | // TODO: refactor how rendering is handled as this error will not be shown
113 | modal.ShowError(d.App.Pages, "Failed to list databases and collections", nil)
114 | d.dbsWithColls = []mongo.DBsWithCollections{}
115 | }
116 |
117 | d.DbTree.Render(ctx, d.dbsWithColls, false)
118 |
119 | d.Flex.AddItem(d.DbTree, 0, 1, true)
120 | }
121 |
122 | func (d *Database) IsFocused() bool {
123 | return d.App.GetFocus().GetIdentifier() == d.GetIdentifier() ||
124 | d.App.GetFocus().GetIdentifier() == d.DbTree.GetIdentifier()
125 | }
126 |
127 | func (d *Database) filterBarHandler(ctx context.Context) {
128 | accceptFunc := func(text string) {
129 | d.filter(ctx, text)
130 | }
131 | rejectFunc := func() {
132 | d.Render()
133 | }
134 | d.filterBar.DoneFuncHandler(accceptFunc, rejectFunc)
135 | }
136 |
137 | func (d *Database) filter(ctx context.Context, text string) {
138 | dbsWitColls := d.dbsWithColls
139 | expand := true
140 | filtered := []mongo.DBsWithCollections{}
141 | if text == "" {
142 | filtered = dbsWitColls
143 | expand = false
144 | } else {
145 | re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(text))
146 | for _, db := range dbsWitColls {
147 | matchedDB := re.MatchString(db.DB)
148 | matchedCollections := []string{}
149 |
150 | for _, coll := range db.Collections {
151 | if re.MatchString(coll) {
152 | matchedCollections = append(matchedCollections, coll)
153 | }
154 | }
155 |
156 | if matchedDB || len(matchedCollections) > 0 {
157 | filteredDB := mongo.DBsWithCollections{
158 | DB: db.DB,
159 | Collections: matchedCollections,
160 | }
161 | if matchedDB {
162 | filteredDB.Collections = db.Collections
163 | }
164 | filtered = append(filtered, filteredDB)
165 | expand = expand || len(matchedCollections) > 0
166 | }
167 | }
168 | }
169 | d.DbTree.Render(ctx, filtered, expand)
170 |
171 | d.Flex.RemoveItem(d.filterBar)
172 |
173 | d.App.SetFocus(d.DbTree)
174 | }
175 |
176 | func (d *Database) listDbsAndCollections(ctx context.Context) error {
177 | dbsWitColls, err := d.Dao.ListDbsWithCollections(ctx, "")
178 | if err != nil {
179 | return err
180 | }
181 | d.dbsWithColls = dbsWitColls
182 |
183 | return nil
184 | }
185 |
186 | func (d *Database) SetSelectFunc(f func(ctx context.Context, db string, coll string) error) {
187 | d.DbTree.SetSelectFunc(f)
188 | }
189 |
190 | func (d *Database) JumpToCollection(ctx context.Context, dbName, collectionName string) error {
191 | if err := d.listDbsAndCollections(ctx); err != nil {
192 | return err
193 | }
194 |
195 | dbExists, collectionExists := d.checkIfDbAndCollectionExist(dbName, collectionName)
196 | if !dbExists || !collectionExists {
197 | return fmt.Errorf("collection %s not found in database %s", collectionName, dbName)
198 | }
199 |
200 | d.DbTree.Render(ctx, d.dbsWithColls, false)
201 |
202 | return d.DbTree.JumpToCollection(ctx, dbName, collectionName)
203 | }
204 |
205 | func (d *Database) checkIfDbAndCollectionExist(dbName, collectionName string) (bool, bool) {
206 | dbExists := false
207 | collectionExists := false
208 |
209 | for _, db := range d.dbsWithColls {
210 | if db.DB == dbName {
211 | dbExists = true
212 | collectionExists = slices.Contains(db.Collections, collectionName)
213 | break
214 | }
215 | }
216 |
217 | return dbExists, collectionExists
218 | }
219 |
--------------------------------------------------------------------------------
/internal/util/encrypt_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestGenerateEncryptionKey(t *testing.T) {
9 | key, err := GenerateEncryptionKey()
10 | if err != nil {
11 | t.Fatalf("GenerateEncryptionKey failed: %v", err)
12 | }
13 |
14 | if len(key) != 2*KeyLength {
15 | t.Errorf("Expected key length %d, got %d", 2*KeyLength, len(key))
16 | }
17 |
18 | key2, err := GenerateEncryptionKey()
19 | if err != nil {
20 | t.Fatalf("Second GenerateEncryptionKey failed: %v", err)
21 | }
22 |
23 | if key == key2 {
24 | t.Error("Generated keys should be unique")
25 | }
26 | }
27 |
28 | func TestGetEncryptionKey(t *testing.T) {
29 | if err := os.Unsetenv(EncryptionKeyEnv); err != nil {
30 | t.Fatalf("Failed to unset environment variable: %v", err)
31 | }
32 | if key := GetEncryptionKey(); key != "" {
33 | t.Errorf("Expected empty key when env var not set, got %s", key)
34 | }
35 |
36 | testKey := "test-encryption-key"
37 | if err := os.Setenv(EncryptionKeyEnv, testKey); err != nil {
38 | t.Fatalf("Failed to set environment variable: %v", err)
39 | }
40 | if key := GetEncryptionKey(); key != testKey {
41 | t.Errorf("Expected key %s, got %s", testKey, key)
42 | }
43 | }
44 |
45 | func TestEncryptDecryptPassword(t *testing.T) {
46 | key, err := GenerateEncryptionKey()
47 | if err != nil {
48 | t.Fatalf("Failed to generate key: %v", err)
49 | }
50 |
51 | testCases := []struct {
52 | name string
53 | password string
54 | key string
55 | wantErr bool
56 | }{
57 | {
58 | name: "Valid password and key",
59 | password: "my-secure-password",
60 | key: key,
61 | wantErr: false,
62 | },
63 | {
64 | name: "Empty password",
65 | password: "",
66 | key: key,
67 | wantErr: false,
68 | },
69 | {
70 | name: "Invalid key format",
71 | password: "password",
72 | key: "not-a-hex-string",
73 | wantErr: true,
74 | },
75 | {
76 | name: "Key too short",
77 | password: "password",
78 | key: "1234",
79 | wantErr: true,
80 | },
81 | }
82 |
83 | for _, tc := range testCases {
84 | t.Run(tc.name, func(t *testing.T) {
85 | encrypted, err := EncryptPassword(tc.password, tc.key)
86 | if tc.wantErr {
87 | if err == nil {
88 | t.Error("Expected error but got none")
89 | }
90 | if _, ok := err.(*EncryptionError); !ok && err != nil {
91 | t.Errorf("Expected EncryptionError, got %T", err)
92 | }
93 | return
94 | }
95 | if err != nil {
96 | t.Fatalf("Unexpected error: %v", err)
97 | }
98 |
99 | if tc.password == "" && encrypted != "" {
100 | t.Error("Empty password should result in empty encrypted string")
101 | }
102 |
103 | if tc.password == "" {
104 | return
105 | }
106 |
107 | decrypted, err := DecryptPassword(encrypted, tc.key)
108 | if err != nil {
109 | t.Fatalf("Decryption failed: %v", err)
110 | }
111 |
112 | if decrypted != tc.password {
113 | t.Errorf("Expected decrypted password %s, got %s", tc.password, decrypted)
114 | }
115 | })
116 | }
117 | }
118 |
119 | func TestDecryptPassword_Errors(t *testing.T) {
120 | key, _ := GenerateEncryptionKey()
121 |
122 | testCases := []struct {
123 | name string
124 | encryptedHex string
125 | key string
126 | wantErr bool
127 | }{
128 | {
129 | name: "Empty encrypted password",
130 | encryptedHex: "",
131 | key: key,
132 | wantErr: false,
133 | },
134 | {
135 | name: "Invalid hex in encrypted password",
136 | encryptedHex: "not-a-hex-string",
137 | key: key,
138 | wantErr: true,
139 | },
140 | {
141 | name: "Ciphertext too short",
142 | encryptedHex: "1234",
143 | key: key,
144 | wantErr: true,
145 | },
146 | {
147 | name: "Invalid key",
148 | encryptedHex: "1234567890abcdef",
149 | key: "invalid-key",
150 | wantErr: true,
151 | },
152 | }
153 |
154 | for _, tc := range testCases {
155 | t.Run(tc.name, func(t *testing.T) {
156 | _, err := DecryptPassword(tc.encryptedHex, tc.key)
157 | if tc.wantErr {
158 | if err == nil {
159 | t.Error("Expected error but got none")
160 | }
161 | if _, ok := err.(*EncryptionError); !ok && err != nil {
162 | t.Errorf("Expected EncryptionError, got %T", err)
163 | }
164 | } else if err != nil {
165 | t.Errorf("Unexpected error: %v", err)
166 | }
167 | })
168 | }
169 | }
170 |
171 | func TestEncryptionError(t *testing.T) {
172 | baseErr := os.ErrNotExist
173 | encErr := &EncryptionError{
174 | Operation: "test operation",
175 | Err: baseErr,
176 | }
177 |
178 | expectedMsg := "encryption error during test operation: file does not exist"
179 | if encErr.Error() != expectedMsg {
180 | t.Errorf("Expected error message %q, got %q", expectedMsg, encErr.Error())
181 | }
182 |
183 | unwrapped := encErr.Unwrap()
184 | if unwrapped != baseErr {
185 | t.Errorf("Expected unwrapped error to be %v, got %v", baseErr, unwrapped)
186 | }
187 | }
188 |
189 | func TestEncryptDecryptRoundTrip(t *testing.T) {
190 | key, err := GenerateEncryptionKey()
191 | if err != nil {
192 | t.Fatalf("Failed to generate key: %v", err)
193 | }
194 |
195 | passwords := []string{
196 | "simple",
197 | "Complex P@ssw0rd!",
198 | "Very long password with spaces and special characters: !@#$%^&*()",
199 | "Password with unicode: 你好世界",
200 | }
201 |
202 | for _, password := range passwords {
203 | t.Run(password, func(t *testing.T) {
204 | encrypted, err := EncryptPassword(password, key)
205 | if err != nil {
206 | t.Fatalf("Encryption failed: %v", err)
207 | }
208 |
209 | decrypted, err := DecryptPassword(encrypted, key)
210 | if err != nil {
211 | t.Fatalf("Decryption failed: %v", err)
212 | }
213 |
214 | if decrypted != password {
215 | t.Errorf("Expected %q after round trip, got %q", password, decrypted)
216 | }
217 |
218 | encrypted2, _ := EncryptPassword(password, key)
219 | if encrypted == encrypted2 {
220 | t.Error("Encryption should not be deterministic")
221 | }
222 | })
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/internal/mongo/state.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "reflect"
5 | "strings"
6 | "sync"
7 |
8 | "github.com/kopecmaciej/vi-mongo/internal/util"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | // CollectionState is used to store the state of a collection and use it
13 | // while rendering doesn't require fetching from the database
14 | type CollectionState struct {
15 | Db string
16 | Coll string
17 | Skip int64
18 | Limit int64
19 | Count int64
20 | Sort string
21 | Filter string
22 | Projection string
23 | // docs are only one private as they cannot be changed in uncontrolled way
24 | docs []primitive.M
25 | }
26 |
27 | func (c *CollectionState) GetAllDocs() []primitive.M {
28 | docsCopy := make([]primitive.M, len(c.docs))
29 | for i, doc := range c.docs {
30 | docsCopy[i] = util.DeepCopy(doc)
31 | }
32 | return docsCopy
33 | }
34 |
35 | func (c *CollectionState) GetDocById(id any) primitive.M {
36 | for _, doc := range c.docs {
37 | if reflect.TypeOf(doc["_id"]) == reflect.TypeOf(id) {
38 | if reflect.DeepEqual(doc["_id"], id) {
39 | return util.DeepCopy(doc)
40 | }
41 | }
42 | }
43 | return nil
44 | }
45 |
46 | func (c *CollectionState) GetJsonDocById(id any) (string, error) {
47 | doc := c.GetDocById(id)
48 | jsoned, err := ParseBsonDocument(doc)
49 | if err != nil {
50 | return "", err
51 | }
52 | indentedJson, err := IndentJson(jsoned)
53 | if err != nil {
54 | return "", err
55 | }
56 | return indentedJson.String(), nil
57 | }
58 |
59 | // GetValueByIdAndColumn returns a stringified value from the document identified by id at the given column.
60 | func (c *CollectionState) GetValueByIdAndColumn(id any, column string) (string, error) {
61 | doc := c.GetDocById(id)
62 | if doc == nil {
63 | return "", nil
64 | }
65 |
66 | value := c.getFieldValue(doc, column)
67 | return util.StringifyMongoValueByType(value), nil
68 | }
69 |
70 | func (c *CollectionState) getFieldValue(doc primitive.M, field string) any {
71 | fields := strings.Split(field, ".")
72 | current := doc
73 |
74 | for i, field := range fields {
75 | if i == len(fields)-1 {
76 | return current[field]
77 | }
78 |
79 | if val, exists := current[field]; exists {
80 | if nested, ok := val.(primitive.M); ok {
81 | current = nested
82 | } else {
83 | return nil
84 | }
85 | } else {
86 | return nil
87 | }
88 | }
89 |
90 | return nil
91 | }
92 |
93 | func (c *CollectionState) SetSkip(skip int64) {
94 | if skip < 0 {
95 | c.Skip = 0
96 | } else {
97 | c.Skip = skip
98 | }
99 | }
100 |
101 | func (c *CollectionState) GetCurrentPage() int64 {
102 | if c.Limit == 0 {
103 | return 1
104 | }
105 | return (c.Skip / c.Limit) + 1
106 | }
107 |
108 | func (c *CollectionState) GetTotalPages() int64 {
109 | if c.Limit == 0 {
110 | return 1
111 | }
112 |
113 | totalPages := c.Count / c.Limit
114 | if c.Count%c.Limit > 0 {
115 | totalPages++
116 | }
117 | return totalPages
118 | }
119 |
120 | func NewCollectionState(db, coll string) *CollectionState {
121 | return &CollectionState{
122 | Db: db,
123 | Coll: coll,
124 | Skip: 0,
125 | }
126 | }
127 |
128 | func (c *CollectionState) SetFilter(filter string) {
129 | filter = util.CleanJsonWhitespaces(filter)
130 | if util.IsJsonEmpty(filter) {
131 | c.Filter = ""
132 | return
133 | }
134 | c.Filter = filter
135 | c.Skip = 0
136 | }
137 |
138 | func (c *CollectionState) SetSort(sort string) {
139 | sort = util.CleanJsonWhitespaces(sort)
140 | if util.IsJsonEmpty(sort) {
141 | c.Sort = ""
142 | return
143 | }
144 | c.Sort = sort
145 | }
146 |
147 | func (c *CollectionState) SetProjection(projection string) {
148 | projection = util.CleanJsonWhitespaces(projection)
149 | if util.IsJsonEmpty(projection) {
150 | c.Projection = ""
151 | return
152 | }
153 | c.Projection = projection
154 | }
155 |
156 | func (c *CollectionState) PopulateDocs(docs []primitive.M) {
157 | c.docs = make([]primitive.M, len(docs))
158 | for i, doc := range docs {
159 | c.docs[i] = util.DeepCopy(doc)
160 | }
161 | }
162 |
163 | func (c *CollectionState) UpdateRawDoc(doc string) error {
164 | docMap, err := ParseJsonToBson(doc)
165 | if err != nil {
166 | return err
167 | }
168 | for i, existingDoc := range c.docs {
169 | if reflect.DeepEqual(existingDoc["_id"], docMap["_id"]) {
170 | c.docs[i] = docMap
171 | return nil
172 | }
173 | }
174 | c.docs = append(c.docs, docMap)
175 | return nil
176 | }
177 |
178 | func (c *CollectionState) AppendDoc(doc primitive.M) {
179 | c.docs = append(c.docs, doc)
180 | c.Count++
181 | }
182 |
183 | func (c *CollectionState) DeleteDoc(id any) {
184 | for i, doc := range c.docs {
185 | if reflect.DeepEqual(doc["_id"], id) {
186 | c.docs = append(c.docs[:i], c.docs[i+1:]...)
187 | c.Count--
188 | return
189 | }
190 | }
191 | }
192 |
193 | // StateMap persevere states when hopping between diffrent mongodb servers
194 | type StateMap struct {
195 | mu sync.RWMutex
196 | states map[string]*CollectionState
197 | hiddenColumns map[string][]string
198 | }
199 |
200 | func (sm *StateMap) AddHiddenColumn(db, coll, column string) {
201 | sm.mu.Lock()
202 | defer sm.mu.Unlock()
203 | key := sm.Key(db, coll)
204 | sm.hiddenColumns[key] = append(sm.hiddenColumns[key], column)
205 | }
206 |
207 | func (sm *StateMap) GetHiddenColumns(db, coll string) []string {
208 | sm.mu.RLock()
209 | defer sm.mu.RUnlock()
210 | key := sm.Key(db, coll)
211 | return sm.hiddenColumns[key]
212 | }
213 |
214 | func (sm *StateMap) ResetHiddenColumns(db, coll string) {
215 | sm.mu.Lock()
216 | defer sm.mu.Unlock()
217 | key := sm.Key(db, coll)
218 | sm.hiddenColumns[key] = nil
219 | }
220 |
221 | func NewStateMap() *StateMap {
222 | return &StateMap{
223 | states: make(map[string]*CollectionState),
224 | hiddenColumns: make(map[string][]string),
225 | }
226 | }
227 |
228 | func (sm *StateMap) Get(key string) (*CollectionState, bool) {
229 | sm.mu.RLock()
230 | defer sm.mu.RUnlock()
231 | state, ok := sm.states[key]
232 | return state, ok
233 | }
234 |
235 | func (sm *StateMap) Set(key string, state *CollectionState) {
236 | sm.mu.Lock()
237 | defer sm.mu.Unlock()
238 | sm.states[key] = state
239 | }
240 |
241 | func (sm *StateMap) Key(db, coll string) string {
242 | return db + "." + coll
243 | }
244 |
--------------------------------------------------------------------------------
/internal/tui/component/ai_query.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "slices"
7 | "strings"
8 |
9 | "github.com/gdamore/tcell/v2"
10 | "github.com/kopecmaciej/tview"
11 | "github.com/kopecmaciej/vi-mongo/internal/ai"
12 | "github.com/kopecmaciej/vi-mongo/internal/manager"
13 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
14 | )
15 |
16 | const (
17 | AIQueryId = "AIQuery"
18 | )
19 |
20 | type AIQuery struct {
21 | *core.BaseElement
22 | *core.FormModal
23 |
24 | docKeys []string
25 | }
26 |
27 | func NewAIQuery() *AIQuery {
28 | formModal := core.NewFormModal()
29 | a := &AIQuery{
30 | BaseElement: core.NewBaseElement(),
31 | FormModal: formModal,
32 | }
33 |
34 | a.SetIdentifier(AIQueryId)
35 | a.SetAfterInitFunc(a.init)
36 |
37 | return a
38 | }
39 |
40 | func (a *AIQuery) init() error {
41 | a.setLayout()
42 | a.setStyle()
43 | a.setKeybindings()
44 |
45 | a.handleEvents()
46 |
47 | return nil
48 | }
49 |
50 | func (a *AIQuery) setLayout() {
51 | a.SetBorder(true)
52 | a.SetTitle(" AI Query ")
53 | a.SetTitleAlign(tview.AlignCenter)
54 | a.Form.SetBorderPadding(2, 2, 2, 2)
55 | }
56 |
57 | func (a *AIQuery) setStyle() {
58 | styles := a.App.GetStyles()
59 | a.SetStyle(styles)
60 |
61 | a.Form.SetFieldTextColor(styles.Connection.FormInputColor.Color())
62 | a.Form.SetFieldBackgroundColor(styles.Connection.FormInputBackgroundColor.Color())
63 | a.Form.SetLabelColor(styles.Connection.FormLabelColor.Color())
64 | }
65 |
66 | func (a *AIQuery) setKeybindings() {
67 | k := a.App.GetKeys()
68 | a.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
69 | switch {
70 | case k.Contains(k.AIQuery.ExitAIQuery, event.Name()):
71 | a.App.Pages.RemovePage(AIQueryId)
72 | return nil
73 | case k.Contains(k.AIQuery.ClearPrompt, event.Name()):
74 | a.Form.GetFormItem(1).(*tview.InputField).SetText("")
75 | return nil
76 | }
77 | return event
78 | })
79 | }
80 |
81 | func (a *AIQuery) IsAIQueryFocused() bool {
82 | if a.App.GetFocus() == a.FormModal {
83 | return true
84 | }
85 | if a.App.GetFocus().GetIdentifier() == a.GetIdentifier() {
86 | return true
87 | }
88 | return false
89 | }
90 |
91 | func (a *AIQuery) handleEvents() {
92 | go a.HandleEvents(AIQueryId, func(event manager.EventMsg) {
93 | switch event.Message.Type {
94 | case manager.StyleChanged:
95 | a.setStyle()
96 | a.Render()
97 | case manager.UpdateAutocompleteKeys:
98 | a.docKeys = event.Message.Data.([]string)
99 | }
100 | })
101 | }
102 |
103 | func (a *AIQuery) Render() {
104 | a.Form.Clear(true)
105 |
106 | models, defaultModelIndex := ai.GetAiModels()
107 |
108 | a.Form.AddDropDown("Model:", models, defaultModelIndex, nil).
109 | AddInputField("Prompt:", "", 0, nil, nil).
110 | AddButton("Ask LLM", a.onSubmit).
111 | AddButton("Apply Query", a.onApplyQuery).
112 | AddTextView("Response:", "", 0, 3, true, false)
113 | }
114 |
115 | func (a *AIQuery) onSubmit() {
116 | var driver ai.AIDriver
117 |
118 | _, model := a.Form.GetFormItem(0).(*tview.DropDown).GetCurrentOption()
119 | prompt := a.Form.GetFormItem(1).(*tview.InputField).GetText()
120 |
121 | gptModels, _ := ai.GetGptModels()
122 | anthropicModels, _ := ai.GetAnthropicModels()
123 | switch {
124 | case slices.Contains(gptModels, model):
125 | apiKey := os.Getenv("OPENAI_API_KEY")
126 | if apiKey == "" {
127 | a.showError("OPENAI_API_KEY not found in environment variables")
128 | return
129 | }
130 | apiUrl := os.Getenv("OPENAI_API_BASE")
131 | driver = ai.NewOpenAIDriver(apiKey, apiUrl)
132 | case slices.Contains(anthropicModels, model):
133 | apiKey := os.Getenv("ANTHROPIC_API_KEY")
134 | if apiKey == "" {
135 | a.showError("ANTHROPIC_API_KEY not found in environment variables")
136 | return
137 | }
138 | driver = ai.NewAnthropicDriver(apiKey)
139 | default:
140 | a.showError(fmt.Sprintf("Invalid AI model selected: %s", model))
141 | return
142 | }
143 |
144 | systemMessage := fmt.Sprintf(`You are an assistant helping to create MongoDB queries.
145 | Respond with valid MongoDB query syntax that can be directly used in a query bar.
146 |
147 | Rules:
148 | 1. Always use proper MongoDB operators (e.g., $regex, $exists, $gt, $lt, $in).
149 | 2. You can use mongosh-style helper functions:
150 | - ISODate("2024-01-01T00:00:00Z") for dates
151 | - NumberInt(42) for 32-bit integers
152 | - NumberLong(123456789) for 64-bit integers
153 | - NumberDecimal("19.99") for decimal values
154 | - ObjectID("507f1f77bcf86cd799439011") or ObjectId("...") for object IDs
155 | 3. Quote values that are not numbers or booleans.
156 | 4. For regex patterns, you can use either:
157 | - JavaScript-style shorthand: /pattern/flags (e.g., /^john/i, /example\.com$/)
158 | - MongoDB operators: { $regex: "pattern", $options: "flags" }
159 | 5. Dates are in ISO 8601 format: 2024-01-01T00:00:00.000Z
160 |
161 | Available document keys: %s
162 |
163 | If the user makes a mistake with a key name, correct it based on the available keys.
164 |
165 | Important: Respond only with the exact query, without any additional explanation,
166 | Examples:
167 | - { email: /example\.com$/i, status: "active" }
168 | - { name: /^john/i, age: NumberInt(30), createdAt: ISODate("2024-01-01T00:00:00Z") }
169 | - { price: NumberDecimal("19.99"), views: NumberLong(999999), isActive: true }
170 | `, strings.Join(a.docKeys, ", "))
171 |
172 | driver.SetSystemMessage(systemMessage)
173 |
174 | response, err := driver.GetResponse(prompt, model)
175 | if err != nil {
176 | a.showError(fmt.Sprintf("Error getting response: %v", err))
177 | return
178 | }
179 |
180 | a.showResponse(response)
181 | }
182 |
183 | func (a *AIQuery) showError(message string) {
184 | a.Form.GetFormItem(2).(*tview.TextView).SetText(fmt.Sprintf("Error: %s", message)).SetTextColor(tcell.ColorRed)
185 | }
186 |
187 | func (a *AIQuery) showResponse(response string) {
188 | a.Form.GetFormItem(2).(*tview.TextView).SetText(fmt.Sprintf("%s", response)).SetTextColor(tcell.ColorGreen)
189 | }
190 |
191 | func (a *AIQuery) onApplyQuery() {
192 | response := a.Form.GetFormItem(2).(*tview.TextView).GetText(true)
193 | if response == "" {
194 | a.showError("No query to apply. Please submit a prompt first.")
195 | return
196 | }
197 |
198 | a.App.GetManager().SendTo(ContentId, manager.EventMsg{
199 | Sender: a.GetIdentifier(),
200 | Message: manager.Message{
201 | Type: manager.UpdateQueryBar,
202 | Data: response,
203 | },
204 | })
205 |
206 | a.App.Pages.RemovePage(AIQueryId)
207 | }
208 |
--------------------------------------------------------------------------------
/internal/tui/page/main.go:
--------------------------------------------------------------------------------
1 | package page
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/gdamore/tcell/v2"
9 | "github.com/kopecmaciej/tview"
10 | "github.com/kopecmaciej/vi-mongo/internal/manager"
11 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
12 | "github.com/kopecmaciej/vi-mongo/internal/tui/component"
13 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
14 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
15 | )
16 |
17 | const (
18 | MainPageId = "Main"
19 | )
20 |
21 | type Main struct {
22 | *core.BaseElement
23 | *core.Flex
24 |
25 | innerFlex *core.Flex
26 | header *component.Header
27 | tabBar *component.TabBar
28 | databases *component.Database
29 | content *component.Content
30 | index *component.Index
31 | aiPrompt *component.AIQuery
32 | }
33 |
34 | func NewMain() *Main {
35 | m := &Main{
36 | BaseElement: core.NewBaseElement(),
37 | Flex: core.NewFlex(),
38 | innerFlex: core.NewFlex(),
39 | header: component.NewHeader(),
40 | tabBar: component.NewTabBar(),
41 | databases: component.NewDatabase(),
42 | content: component.NewContent(),
43 | index: component.NewIndex(),
44 | aiPrompt: component.NewAIQuery(),
45 | }
46 |
47 | m.SetIdentifier(MainPageId)
48 | m.SetAfterInitFunc(m.init)
49 |
50 | return m
51 | }
52 |
53 | func (m *Main) init() error {
54 | m.setStyles()
55 | m.setKeybindings()
56 |
57 | m.handleEvents()
58 |
59 | return m.initComponents()
60 | }
61 |
62 | func (m *Main) setStyles() {
63 | m.SetStyle(m.App.GetStyles())
64 | m.innerFlex.SetStyle(m.App.GetStyles())
65 | m.innerFlex.SetDirection(tview.FlexRow)
66 | }
67 |
68 | func (m *Main) handleEvents() {
69 | go m.HandleEvents(MainPageId, func(event manager.EventMsg) {
70 | switch event.Message.Type {
71 | case manager.StyleChanged:
72 | m.setStyles()
73 | }
74 | })
75 | }
76 |
77 | func (m *Main) initComponents() error {
78 | if err := m.header.Init(m.App); err != nil {
79 | return err
80 | }
81 |
82 | if err := m.tabBar.Init(m.App); err != nil {
83 | return err
84 | }
85 |
86 | if err := m.databases.Init(m.App); err != nil {
87 | return err
88 | }
89 | if err := m.content.Init(m.App); err != nil {
90 | return err
91 | }
92 |
93 | if err := m.index.Init(m.App); err != nil {
94 | return err
95 | }
96 |
97 | if err := m.aiPrompt.Init(m.App); err != nil {
98 | return err
99 | }
100 |
101 | m.tabBar.AddTab("Content", m.content, true)
102 | m.tabBar.AddTab("Indexes", m.index, false)
103 |
104 | return nil
105 | }
106 |
107 | func (m *Main) Render() {
108 | m.databases.Render()
109 | m.header.Render()
110 | m.tabBar.Render()
111 |
112 | m.databases.SetSelectFunc(func(ctx context.Context, db, coll string) error {
113 | err := m.content.HandleDatabaseSelection(ctx, db, coll)
114 | if err != nil {
115 | return err
116 | }
117 | m.index.HandleDatabaseSelection(ctx, db, coll)
118 | m.App.SetFocus(m.tabBar.GetActiveComponent())
119 | return nil
120 | })
121 |
122 | m.render()
123 | }
124 |
125 | // UpdateDao updates the dao in the components
126 | func (m *Main) UpdateDao(dao *mongo.Dao) {
127 | m.databases.UpdateDao(dao)
128 | m.header.UpdateDao(dao)
129 | m.content.UpdateDao(dao)
130 | m.index.UpdateDao(dao)
131 | }
132 |
133 | func (m *Main) JumpToCollection(dbName, collectionName string) error {
134 | ctx := context.Background()
135 |
136 | if err := m.databases.JumpToCollection(ctx, dbName, collectionName); err != nil {
137 | return err
138 | }
139 |
140 | err := m.content.HandleDatabaseSelection(ctx, dbName, collectionName)
141 | if err != nil {
142 | return fmt.Errorf("failed to load content for %s/%s: %w", dbName, collectionName, err)
143 | }
144 |
145 | m.index.HandleDatabaseSelection(ctx, dbName, collectionName)
146 |
147 | m.App.SetFocus(m.tabBar.GetActiveComponent())
148 |
149 | return nil
150 | }
151 |
152 | func (m *Main) render() {
153 | m.Clear()
154 | m.innerFlex.Clear()
155 |
156 | m.AddItem(m.databases, 30, 0, true)
157 | m.AddItem(m.innerFlex, 0, 7, false)
158 | m.innerFlex.AddItem(m.header, 4, 0, false)
159 | m.innerFlex.AddItem(m.tabBar, 1, 0, false)
160 | m.innerFlex.AddItem(m.tabBar.GetActiveComponentAndRender(), 0, 7, true)
161 |
162 | m.App.Pages.AddPage(m.GetIdentifier(), m, true, true)
163 | m.App.SetFocus(m)
164 | }
165 |
166 | func (m *Main) setKeybindings() {
167 | k := m.App.GetKeys()
168 | m.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
169 | switch {
170 | case k.Contains(k.Main.FocusNext, event.Name()):
171 | // TODO: figure out how to handle key priorities
172 | if m.index.IsAddFormFocused() || m.aiPrompt.IsAIQueryFocused() {
173 | return event
174 | }
175 | if m.databases.IsFocused() {
176 | m.App.SetFocus(m.tabBar.GetActiveComponent())
177 | } else {
178 | m.innerFlex.RemoveItem(m.tabBar.GetActiveComponent())
179 | m.tabBar.NextTab()
180 | m.innerFlex.AddItem(m.tabBar.GetActiveComponentAndRender(), 0, 7, true)
181 |
182 | m.App.SetFocus(m.tabBar.GetActiveComponent())
183 | }
184 | return nil
185 | case k.Contains(k.Main.FocusPrevious, event.Name()):
186 | if m.index.IsAddFormFocused() || m.aiPrompt.IsAIQueryFocused() {
187 | return event
188 | }
189 | if m.tabBar.GetActiveTabIndex() == 0 {
190 | m.App.SetFocus(m.databases)
191 | } else {
192 | m.innerFlex.RemoveItem(m.tabBar.GetActiveComponent())
193 | m.tabBar.PreviousTab()
194 | m.innerFlex.AddItem(m.tabBar.GetActiveComponentAndRender(), 0, 7, true)
195 | m.App.SetFocus(m.tabBar.GetActiveComponent())
196 | }
197 | return nil
198 | case k.Contains(k.Main.HideDatabase, event.Name()):
199 | if _, ok := m.GetItem(0).(*component.Database); ok {
200 | m.RemoveItem(m.databases)
201 | m.App.SetFocus(m.tabBar.GetActiveComponent())
202 | } else {
203 | m.Clear()
204 | m.render()
205 | }
206 | return nil
207 | case k.Contains(k.Main.ShowServerInfo, event.Name()):
208 | m.ShowServerInfoModal()
209 | return nil
210 | case k.Contains(k.Main.ShowAIQuery, event.Name()):
211 | m.ShowAIPrompt()
212 | return nil
213 | }
214 | return event
215 | })
216 | }
217 |
218 | func (m *Main) ShowServerInfoModal() {
219 | serverInfoModal := modal.NewServerInfoModal(m.Dao)
220 | serverInfoModal.Init(m.App)
221 |
222 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
223 | defer cancel()
224 |
225 | if err := serverInfoModal.Render(ctx); err != nil {
226 | modal.ShowError(m.App.Pages, "Failed to render server info modal", err)
227 | return
228 | }
229 |
230 | m.App.Pages.AddPage(modal.ServerInfoModalId, serverInfoModal, true, true)
231 | }
232 |
233 | func (m *Main) ShowAIPrompt() {
234 | m.aiPrompt.Render()
235 | m.App.Pages.AddPage(component.AIQueryId, m.aiPrompt, true, true)
236 | }
237 |
--------------------------------------------------------------------------------
/internal/mongo/parser.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "sort"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/kopecmaciej/vi-mongo/internal/util"
12 | "github.com/rs/zerolog/log"
13 | "go.mongodb.org/mongo-driver/bson"
14 | "go.mongodb.org/mongo-driver/bson/primitive"
15 | )
16 |
17 | // ParseBsonDocument converts a map to a JSON string
18 | func ParseBsonDocument(document map[string]any) (string, error) {
19 | // convert id to oid
20 | converted, err := ParseBsonDocuments([]primitive.M{document})
21 | if err != nil {
22 | return "", err
23 | }
24 | return converted[0], nil
25 | }
26 |
27 | // ParseBsonDocuments converts a slice of documents to a slice of strings with
28 | // mongo compatible JSON
29 | func ParseBsonDocuments(documents []primitive.M) ([]string, error) {
30 | var docs []string
31 |
32 | for _, doc := range documents {
33 | sortedDoc := sortDocumentKeys(doc)
34 | jsonBytes, err := bson.MarshalExtJSON(sortedDoc, false, false)
35 | if err != nil {
36 | log.Error().Err(err).Msg("Error marshaling JSON")
37 | continue
38 | }
39 | docs = append(docs, string(jsonBytes))
40 | }
41 |
42 | return docs, nil
43 | }
44 |
45 | // TODO: Remove this and convert everything to primitive.D
46 | func sortDocumentKeys(doc primitive.M) primitive.D {
47 | keys := make([]string, 0, len(doc))
48 | for key := range doc {
49 | keys = append(keys, key)
50 | }
51 |
52 | sort.SliceStable(keys, func(i, j int) bool {
53 | return strings.Compare(keys[i], keys[j]) < 0
54 | })
55 |
56 | sortedDoc := primitive.D{}
57 | for _, key := range keys {
58 | value := doc[key]
59 | sortedValue := sortValue(value)
60 | sortedDoc = append(sortedDoc, bson.E{Key: key, Value: sortedValue})
61 | }
62 |
63 | return sortedDoc
64 | }
65 |
66 | func sortValue(value any) any {
67 | switch v := value.(type) {
68 | case primitive.M:
69 | return sortDocumentKeys(v)
70 | case []any:
71 | return sortArray(v)
72 | case primitive.A:
73 | return sortArray([]any(v))
74 | default:
75 | return value
76 | }
77 | }
78 |
79 | func sortArray(arr []any) primitive.A {
80 | sorted := make(primitive.A, len(arr))
81 | for i, v := range arr {
82 | sorted[i] = sortValue(v)
83 | }
84 | return sorted
85 | }
86 |
87 | // ParseStringQuery transforms a query string into a filter map compatible with MongoDB's BSON.
88 | // It transforms special Mongodb JS syntax into proper BSON
89 | func ParseStringQuery(query string) (map[string]any, error) {
90 | if query == "" {
91 | return map[string]any{}, nil
92 | }
93 |
94 | query = util.QuoteUnquotedKeys(query)
95 | query = util.TransformMongoshSyntax(query)
96 |
97 | query = strings.ReplaceAll(query, "ObjectID(\"", "{\"$oid\": \"")
98 | query = strings.ReplaceAll(query, "ObjectId(\"", "{\"$oid\": \"")
99 | query = strings.ReplaceAll(query, "\")", "\"}")
100 |
101 | var filter primitive.M
102 | err := bson.UnmarshalExtJSON([]byte(query), false, &filter)
103 | if err != nil {
104 | log.Error().Err(err).Msgf("Error parsing query %s", query)
105 | return nil, fmt.Errorf("error parsing query %s: %w", query, err)
106 | }
107 |
108 | filter = util.ConvertRegexInArrays(filter)
109 |
110 | return filter, nil
111 | }
112 |
113 | // ParseSortOptions parses a sort options string into a BSON-compatible map.
114 | func ParseSortOptions(sortOptions string) (map[string]any, error) {
115 | if sortOptions == "" {
116 | return map[string]any{}, nil
117 | }
118 |
119 | sortOptions = util.QuoteUnquotedKeys(sortOptions)
120 |
121 | var sort primitive.M
122 | err := bson.UnmarshalExtJSON([]byte(sortOptions), false, &sort)
123 | if err != nil {
124 | log.Error().Err(err).Msgf("Error parsing sort options %s", sortOptions)
125 | return nil, fmt.Errorf("error parsing sort options %s: %w", sortOptions, err)
126 | }
127 |
128 | return sort, nil
129 | }
130 |
131 | // IndentJson indents a JSON string and returns a a buffer
132 | func IndentJson(jsonString string) (bytes.Buffer, error) {
133 | var prettyJson bytes.Buffer
134 | err := json.Indent(&prettyJson, []byte(jsonString), "", " ")
135 | if err != nil {
136 | log.Error().Err(err).Msg("Error marshaling JSON")
137 | return bytes.Buffer{}, err
138 | }
139 |
140 | return prettyJson, nil
141 | }
142 |
143 | // ParseJsonToBson converts a JSON string to a primitive.M document
144 | func ParseJsonToBson(jsonDoc string) (primitive.M, error) {
145 | var doc primitive.M
146 | err := bson.UnmarshalExtJSON([]byte(jsonDoc), false, &doc)
147 | if err != nil {
148 | log.Error().Err(err).Msg("Error unmarshaling JSON")
149 | return primitive.M{}, fmt.Errorf("error unmarshaling JSON: %w", err)
150 | }
151 | return doc, nil
152 | }
153 |
154 | func ParseValueByType(value string, originalValue any) (any, error) {
155 | if originalValue != nil {
156 | switch originalValue.(type) {
157 | case primitive.M, map[string]interface{}:
158 | if strings.HasPrefix(strings.TrimSpace(value), "{") && strings.HasSuffix(strings.TrimSpace(value), "}") {
159 | if parsed, err := ParseJsonToBson(value); err == nil {
160 | return parsed, nil
161 | }
162 | }
163 | case primitive.A, []any:
164 | if strings.HasPrefix(strings.TrimSpace(value), "[") && strings.HasSuffix(strings.TrimSpace(value), "]") {
165 | return ParseJsonArray(value)
166 | }
167 | case int, int32, int64:
168 | return stringToInt(value)
169 | case float32, float64:
170 | return stringToFloat(value)
171 | case bool:
172 | return stringToBool(value)
173 | }
174 | }
175 |
176 | if strings.HasPrefix(strings.TrimSpace(value), "{") && strings.HasSuffix(strings.TrimSpace(value), "}") {
177 | if parsed, err := ParseJsonToBson(value); err == nil {
178 | return parsed, nil
179 | }
180 | }
181 |
182 | if strings.HasPrefix(strings.TrimSpace(value), "[") && strings.HasSuffix(strings.TrimSpace(value), "]") {
183 | return ParseJsonArray(value)
184 | }
185 |
186 | if value == "true" || value == "false" {
187 | return stringToBool(value)
188 | }
189 |
190 | if intVal, err := stringToInt(value); err == nil {
191 | return intVal, nil
192 | }
193 |
194 | if floatVal, err := stringToFloat(value); err == nil {
195 | return floatVal, nil
196 | }
197 |
198 | return value, nil
199 | }
200 |
201 | func stringToInt(s string) (int64, error) {
202 | return strconv.ParseInt(s, 10, 64)
203 | }
204 |
205 | func stringToFloat(s string) (float64, error) {
206 | return strconv.ParseFloat(s, 64)
207 | }
208 |
209 | func stringToBool(s string) (bool, error) {
210 | return strconv.ParseBool(s)
211 | }
212 |
213 | func ParseJsonArray(value string) (any, error) {
214 | var jsonArray []any
215 | if err := json.Unmarshal([]byte(value), &jsonArray); err != nil {
216 | return value, nil
217 | }
218 |
219 | bsonArray := make(primitive.A, len(jsonArray))
220 | copy(bsonArray, jsonArray)
221 |
222 | return bsonArray, nil
223 | }
224 |
--------------------------------------------------------------------------------
/internal/tui/app.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "os"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/tview"
9 | "github.com/kopecmaciej/vi-mongo/internal/config"
10 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
11 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
12 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
13 | "github.com/kopecmaciej/vi-mongo/internal/tui/page"
14 | "github.com/kopecmaciej/vi-mongo/internal/util"
15 | "github.com/rs/zerolog/log"
16 | )
17 |
18 | type (
19 | // App extends the core.App struct
20 | App struct {
21 | *core.App
22 |
23 | // initial pages
24 | connection *page.Connection
25 | main *page.Main
26 | help *page.Help
27 | }
28 | )
29 |
30 | func NewApp(appConfig *config.Config) *App {
31 | coreApp := core.NewApp(appConfig)
32 |
33 | app := &App{
34 | App: coreApp,
35 |
36 | connection: page.NewConnection(),
37 | main: page.NewMain(),
38 | help: page.NewHelp(),
39 | }
40 |
41 | return app
42 | }
43 |
44 | // Init initializes app
45 | func (a *App) Init() error {
46 | a.SetRoot(a.Pages, true).EnableMouse(true)
47 |
48 | err := a.help.Init(a.App)
49 | if err != nil {
50 | return err
51 | }
52 | a.setKeybindings()
53 |
54 | a.connection.Init(a.App)
55 | return nil
56 | }
57 |
58 | func (a *App) Run() error {
59 | return a.Application.Run()
60 | }
61 |
62 | func (a *App) setKeybindings() {
63 | a.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
64 | if a.shouldHandleRune(event) {
65 | return event
66 | }
67 |
68 | switch {
69 | case a.GetKeys().Contains(a.GetKeys().Global.CloseApp, event.Name()):
70 | a.Stop()
71 | return nil
72 | case a.GetKeys().Contains(a.GetKeys().Global.OpenConnection, event.Name()):
73 | a.renderConnection()
74 | return nil
75 | case a.GetKeys().Contains(a.GetKeys().Global.ShowStyleModal, event.Name()):
76 | a.ShowStyleChangeModal()
77 | return nil
78 | case a.GetKeys().Contains(a.GetKeys().Global.ToggleFullScreenHelp, event.Name()):
79 | if a.Pages.HasPage(page.HelpPageId) {
80 | a.Pages.RemovePage(page.HelpPageId)
81 | return nil
82 | }
83 | a.help.Render()
84 | a.Pages.AddPage(page.HelpPageId, a.help, true, true)
85 | return nil
86 | }
87 | return event
88 | })
89 | }
90 |
91 | // shouldHandleRune checks if the rune event should be passed through to input fields
92 | // it's needed if we want to assign rune to global keybindings and this rune should be captured
93 | // by input fields
94 | func (a *App) shouldHandleRune(event *tcell.EventKey) bool {
95 | if !strings.HasPrefix(event.Name(), "Rune") {
96 | return false
97 | }
98 |
99 | focus := a.GetFocus()
100 | identifier := string(focus.GetIdentifier())
101 |
102 | if strings.Contains(identifier, "Bar") || strings.Contains(identifier, "Input") {
103 | return true
104 | }
105 |
106 | _, isInputField := focus.(*tview.InputField)
107 | _, isCustomInputField := focus.(*core.InputField)
108 | _, isFormItem := focus.(tview.FormItem)
109 |
110 | return isInputField || isCustomInputField || isFormItem
111 | }
112 |
113 | func (a *App) connectToMongo() error {
114 | currConn := a.App.GetConfig().GetCurrentConnection()
115 | if a.GetDao() != nil && *a.GetDao().Config == *currConn {
116 | return nil
117 | }
118 |
119 | client := mongo.NewClient(currConn)
120 | if err := client.Connect(); err != nil {
121 | log.Error().Err(err).Msg("Failed to connect to mongo")
122 | return err
123 | }
124 | if err := client.Ping(); err != nil {
125 | log.Error().Err(err).Msg("Failed to ping to mongo")
126 | return err
127 | }
128 | a.SetDao(mongo.NewDao(client.Client, client.Config))
129 | return nil
130 | }
131 |
132 | // Render is the main render function
133 | // it renders the page based on the config
134 | func (a *App) Render() {
135 | switch {
136 | case a.App.GetConfig().ShowWelcomePage:
137 | a.renderWelcome()
138 | case a.App.GetConfig().GetCurrentConnection() == nil, a.App.GetConfig().ShowConnectionPage:
139 | a.renderConnection()
140 | default:
141 | // we need to init main view after connection is established
142 | // as it depends on the dao
143 | a.initAndRenderMain()
144 | }
145 | }
146 |
147 | // initAndRenderMain initializes and renders the main page
148 | // methods are combined as we need to establish connection first
149 | func (a *App) initAndRenderMain() {
150 | if err := a.connectToMongo(); err != nil {
151 | a.renderConnection()
152 | if _, ok := err.(*util.EncryptionError); ok {
153 | modal.ShowError(a.Pages, "Encryption error occurred", err)
154 | } else {
155 | modal.ShowError(a.Pages, "Error while connecting to mongodb", err)
156 | }
157 | return
158 | }
159 |
160 | // if main view is already initialized, we just update dao
161 | if a.main.App != nil || a.main.Dao != nil {
162 | a.main.UpdateDao(a.GetDao())
163 | } else {
164 | if err := a.main.Init(a.App); err != nil {
165 | log.Fatal().Err(err).Msg("Error while initializing main view")
166 | os.Exit(1)
167 | }
168 | }
169 |
170 | a.main.Render()
171 | a.Pages.AddPage(a.main.GetIdentifier(), a.main, true, true)
172 |
173 | if jumpInto := a.GetConfig().JumpInto; jumpInto != "" {
174 | if err := a.jumpToCollection(jumpInto); err != nil {
175 | modal.ShowError(a.Pages, "Unable to jump into the db/collection", err)
176 | }
177 | }
178 | }
179 |
180 | // renderConnection renders the connection page
181 | func (a *App) renderConnection() {
182 | a.connection.SetOnSubmitFunc(func() {
183 | a.Pages.RemovePage(a.connection.GetIdentifier())
184 | a.initAndRenderMain()
185 | })
186 |
187 | a.Pages.AddPage(a.connection.GetIdentifier(), a.connection, true, true)
188 | a.connection.Render()
189 | }
190 |
191 | // renderWelcome renders the welcome page
192 | // it's initialized inside render function
193 | // as it's probalby won't be used very often
194 | func (a *App) renderWelcome() {
195 | welcome := page.NewWelcome()
196 | if err := welcome.Init(a.App); err != nil {
197 | a.Pages.AddPage(welcome.GetIdentifier(), welcome, true, true)
198 | modal.ShowError(a.Pages, "Error while rendering welcome page", err)
199 | return
200 | }
201 | welcome.SetOnSubmitFunc(func() {
202 | a.Pages.RemovePage(welcome.GetIdentifier())
203 | a.renderConnection()
204 | })
205 | a.Pages.AddPage(welcome.GetIdentifier(), welcome, true, true)
206 | welcome.Render()
207 | }
208 |
209 | func (a *App) ShowStyleChangeModal() {
210 | styleChangeModal := modal.NewStyleChangeModal()
211 | if err := styleChangeModal.Init(a.App); err != nil {
212 | modal.ShowError(a.Pages, "Error while initializing style change modal", err)
213 | }
214 | styleChangeModal.Render()
215 | styleChangeModal.SetApplyStyle(func(styleName string) error {
216 | return a.SetStyle(styleName)
217 | })
218 | }
219 |
220 | func (a *App) jumpToCollection(jumpTo string) error {
221 | parts := strings.Split(jumpTo, "/")
222 |
223 | dbName := strings.TrimSpace(parts[0])
224 | collName := strings.TrimSpace(parts[1])
225 |
226 | return a.main.JumpToCollection(dbName, collName)
227 | }
228 |
--------------------------------------------------------------------------------
/internal/tui/component/input_bar.go:
--------------------------------------------------------------------------------
1 | package component
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 |
7 | "github.com/gdamore/tcell/v2"
8 | "github.com/kopecmaciej/tview"
9 | "github.com/kopecmaciej/vi-mongo/internal/config"
10 | "github.com/kopecmaciej/vi-mongo/internal/manager"
11 | "github.com/kopecmaciej/vi-mongo/internal/mongo"
12 | "github.com/kopecmaciej/vi-mongo/internal/tui/core"
13 | "github.com/kopecmaciej/vi-mongo/internal/tui/modal"
14 | "github.com/kopecmaciej/vi-mongo/internal/util"
15 | "github.com/rs/zerolog/log"
16 | )
17 |
18 | type InputBar struct {
19 | *core.BaseElement
20 | *core.InputField
21 |
22 | historyModal *modal.History
23 | style *config.InputBarStyle
24 | enabled bool
25 | autocompleteOn bool
26 | docKeys []string
27 | defaultText string
28 | }
29 |
30 | func NewInputBar(barId tview.Identifier, label string) *InputBar {
31 | i := &InputBar{
32 | BaseElement: core.NewBaseElement(),
33 | InputField: core.NewInputField(),
34 | enabled: false,
35 | autocompleteOn: false,
36 | }
37 |
38 | i.InputField.SetLabel(" " + label + ": ")
39 |
40 | i.SetIdentifier(barId)
41 | i.SetAfterInitFunc(i.init)
42 |
43 | return i
44 | }
45 |
46 | func (i *InputBar) init() error {
47 | i.setStyle()
48 | i.setKeybindings()
49 | i.setLayout()
50 |
51 | i.SetClipboard(util.GetClipboard())
52 |
53 | i.handleEvents()
54 |
55 | return nil
56 | }
57 |
58 | func (i *InputBar) setLayout() {
59 | i.SetBorder(true)
60 | }
61 |
62 | func (i *InputBar) setStyle() {
63 | i.SetStyle(i.App.GetStyles())
64 | i.style = &i.App.GetStyles().InputBar
65 | i.SetLabelColor(i.style.LabelColor.Color())
66 | i.SetFieldTextColor(i.style.InputColor.Color())
67 |
68 | // Autocomplete styles
69 | a := i.style.Autocomplete
70 | background := a.BackgroundColor.Color()
71 | main := tcell.StyleDefault.
72 | Background(a.BackgroundColor.Color()).
73 | Foreground(a.TextColor.Color())
74 | selected := tcell.StyleDefault.
75 | Background(a.ActiveBackgroundColor.Color()).
76 | Foreground(a.ActiveTextColor.Color())
77 | second := tcell.StyleDefault.
78 | Background(a.BackgroundColor.Color()).
79 | Foreground(a.SecondaryTextColor.Color()).
80 | Italic(true)
81 |
82 | i.SetAutocompleteStyles(background, main, selected, second, true)
83 | }
84 |
85 | func (i *InputBar) setKeybindings() {
86 | i.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
87 | k := i.App.GetKeys()
88 | switch event.Rune() {
89 | case '{':
90 | if i.GetWordAtCursor() == "" {
91 | i.SetWordAtCursor("{ <$0> }")
92 | return nil
93 | }
94 | case '[':
95 | if i.GetWordAtCursor() == "" {
96 | i.SetWordAtCursor("[ <$0> ]")
97 | return nil
98 | }
99 | }
100 |
101 | switch {
102 | case k.Contains(k.QueryBar.ShowHistory, event.Name()):
103 | if i.historyModal != nil {
104 | i.historyModal.Render()
105 | }
106 | case k.Contains(k.QueryBar.ClearInput, event.Name()):
107 | i.SetText("")
108 | go i.SetWordAtCursor(i.defaultText)
109 | }
110 |
111 | return event
112 | })
113 | }
114 |
115 | func (i *InputBar) handleEvents() {
116 | go i.HandleEvents(i.GetIdentifier(), func(event manager.EventMsg) {
117 | switch event.Message.Type {
118 | case manager.StyleChanged:
119 | i.setStyle()
120 | }
121 |
122 | switch sender := event.Sender; {
123 | case i.historyModal != nil && sender == i.historyModal.GetIdentifier():
124 | i.handleHistoryModalEvent(event.EventKey)
125 | }
126 | })
127 | }
128 |
129 | // SetDefaultText sets default text for the input bar
130 | func (i *InputBar) SetDefaultText(text string) {
131 | i.defaultText = text
132 | }
133 |
134 | // DoneFuncHandler sets DoneFunc for the input bar
135 | // It accepts two functions: accept and reject which are called
136 | // when user accepts or rejects the input
137 | func (i *InputBar) DoneFuncHandler(accept func(string), reject func()) {
138 | i.SetDoneFunc(func(key tcell.Key) {
139 | switch key {
140 | case tcell.KeyEsc:
141 | i.Toggle("")
142 | reject()
143 | case tcell.KeyEnter:
144 | i.Toggle("")
145 | text := i.GetText()
146 | accept(text)
147 | }
148 | })
149 | }
150 |
151 | // EnableHistory enables history modal
152 | func (i *InputBar) EnableHistory() {
153 | i.historyModal = modal.NewHistoryModal()
154 |
155 | if err := i.historyModal.Init(i.App); err != nil {
156 | log.Error().Err(err).Msg("Error initializing history modal")
157 | }
158 | }
159 |
160 | // EnableAutocomplete enables autocomplete
161 | func (i *InputBar) EnableAutocomplete() {
162 | ma := mongo.NewMongoAutocomplete()
163 | mongoKeywords := ma.Operators
164 |
165 | i.SetAutocompleteFunc(func(currentText string) (entries []tview.AutocompleteItem) {
166 | currentText = strings.TrimPrefix(currentText, "\"")
167 |
168 | words := strings.Fields(currentText)
169 | if len(words) > 0 {
170 | currentWord := i.GetWordAtCursor()
171 | // if word starts with { or [ then we are inside object or array
172 | // and we should ommmit this character
173 | if strings.HasPrefix(currentWord, "{") || strings.HasPrefix(currentWord, "[") {
174 | currentWord = currentWord[1:]
175 | }
176 | if currentWord == "" {
177 | return nil
178 | }
179 |
180 | // support for mongo keywords
181 | for _, keyword := range mongoKeywords {
182 | escaped := regexp.QuoteMeta(currentWord)
183 | if matched, _ := regexp.MatchString("(?i)^"+escaped, keyword.Display); matched {
184 | entry := tview.AutocompleteItem{Main: keyword.Display, Secondary: keyword.Description}
185 | entries = append(entries, entry)
186 | }
187 | }
188 |
189 | // support for document keys
190 | if i.docKeys != nil {
191 | for _, keyword := range i.docKeys {
192 | if matched, _ := regexp.MatchString("(?i)^"+currentWord, keyword); matched {
193 | entries = append(entries, tview.AutocompleteItem{Main: keyword})
194 | }
195 | }
196 | }
197 | }
198 |
199 | return entries
200 | })
201 |
202 | i.SetAutocompletedFunc(func(text string, index, source int) bool {
203 | if source == 0 {
204 | return false
205 | }
206 |
207 | key := ma.GetOperatorByDisplay(text)
208 | if key != nil {
209 | text = key.InsertText
210 | }
211 |
212 | i.SetWordAtCursor(text)
213 |
214 | return true
215 | })
216 | }
217 |
218 | // LoadAutocomleteKeys loads new keys for autocomplete
219 | // It is used when switching databases or collections
220 | func (i *InputBar) LoadAutocomleteKeys(keys []string) {
221 | i.docKeys = keys
222 | }
223 |
224 | // Draws default text if input is empty
225 | func (i *InputBar) Toggle(text string) {
226 | i.BaseElement.Toggle()
227 | if text == "" {
228 | text = i.GetText()
229 | }
230 | if text == "" {
231 | go i.App.QueueUpdateDraw(func() {
232 | i.SetWordAtCursor(i.defaultText)
233 | })
234 | }
235 | }
236 |
237 | func (i *InputBar) handleHistoryModalEvent(eventKey *tcell.EventKey) {
238 | switch {
239 | case i.App.GetKeys().Contains(i.App.GetKeys().History.AcceptEntry, eventKey.Name()):
240 | go i.App.QueueUpdateDraw(func() {
241 | i.SetText(i.historyModal.GetText())
242 | i.App.SetFocus(i)
243 | })
244 | case i.App.GetKeys().Contains(i.App.GetKeys().History.CloseHistory, eventKey.Name()):
245 | go i.App.QueueUpdateDraw(func() {
246 | i.App.SetFocus(i)
247 | })
248 | default:
249 | return
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/internal/util/config.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "reflect"
9 | "strings"
10 |
11 | "github.com/adrg/xdg"
12 | "github.com/rs/zerolog/log"
13 | "gopkg.in/yaml.v3"
14 | )
15 |
16 | const (
17 | ConfigDir = "vi-mongo"
18 | )
19 |
20 | // MergeConfigs merges the loaded config with the default config
21 | func MergeConfigs(loaded, defaultConfig any) {
22 | loadedVal := reflect.ValueOf(loaded).Elem()
23 | defaultVal := reflect.ValueOf(defaultConfig).Elem()
24 | mergeConfigsRecursive(loadedVal, defaultVal)
25 | }
26 |
27 | // mergeConfigsRecursive recursively merges nested structs.
28 | // This may be a bit complicated for such a simple merge, but it allows for
29 | // more flexibility in the future if we want to add more complex merging logic
30 | // TODO: probably merging keybindings and config should be split into two functions
31 | func mergeConfigsRecursive(loaded, defaultValue reflect.Value) {
32 | for i := 0; i < loaded.NumField(); i++ {
33 | field := loaded.Field(i)
34 | defaultField := defaultValue.Field(i)
35 |
36 | // Special handling for Key structs
37 | if field.Type().Name() == "Key" {
38 | // If any field in the Key struct is set, keep the entire struct as-is
39 | if !isEmptyKey(field) {
40 | continue
41 | }
42 | // If the Key struct is completely empty, use the default
43 | field.Set(defaultField)
44 | continue
45 | }
46 |
47 | switch field.Kind() {
48 | case reflect.String:
49 | if field.String() == "" {
50 | field.Set(defaultField)
51 | }
52 | case reflect.Slice:
53 | if field.Len() == 0 {
54 | field.Set(defaultField)
55 | }
56 | case reflect.Struct:
57 | mergeConfigsRecursive(field, defaultField)
58 | }
59 | }
60 | }
61 |
62 | // isEmptyKey checks if a Key struct is completely empty
63 | func isEmptyKey(keyValue reflect.Value) bool {
64 | for i := 0; i < keyValue.NumField(); i++ {
65 | field := keyValue.Field(i)
66 | switch field.Kind() {
67 | case reflect.String:
68 | if field.String() != "" {
69 | return false
70 | }
71 | case reflect.Slice:
72 | if field.Len() > 0 {
73 | return false
74 | }
75 | }
76 | }
77 | return true
78 | }
79 |
80 | // LoadConfigFile loads a configuration file, merges it with defaults, and returns the result
81 | func LoadConfigFile[T any](defaultConfig *T, configPath string) (*T, error) {
82 | err := ensureConfigDirExist()
83 | if err != nil {
84 | log.Error().Err(err).Msg("Failed to ensure config directory exists")
85 | return nil, fmt.Errorf("failed to ensure config directory exists: %w", err)
86 | }
87 |
88 | bytes, err := os.ReadFile(configPath)
89 | if err != nil {
90 | if os.IsNotExist(err) {
91 | bytes, err = marshalConfig(defaultConfig, configPath)
92 | if err != nil {
93 | log.Error().Err(err).Str("path", configPath).Msg("Failed to marshal default config")
94 | return nil, fmt.Errorf("failed to marshal default config: %w", err)
95 | }
96 | err = os.WriteFile(configPath, bytes, 0644)
97 | if err != nil {
98 | log.Error().Err(err).Str("path", configPath).Msg("Failed to write default config file")
99 | return nil, fmt.Errorf("failed to write default config file: %w", err)
100 | }
101 | return defaultConfig, nil
102 | }
103 | log.Error().Err(err).Str("path", configPath).Msg("Failed to read config file")
104 | return nil, fmt.Errorf("failed to read config file: %w", err)
105 | }
106 |
107 | config := new(T)
108 | err = unmarshalConfig(bytes, configPath, config)
109 | if err != nil {
110 | log.Error().Err(err).Str("path", configPath).Msg("Failed to unmarshal config file")
111 | return nil, fmt.Errorf("failed to unmarshal config file: %w", err)
112 | }
113 |
114 | MergeConfigs(config, defaultConfig)
115 | return config, nil
116 | }
117 |
118 | // marshalConfig marshals the config based on the file extension
119 | func marshalConfig[T any](config *T, configPath string) ([]byte, error) {
120 | switch filepath.Ext(configPath) {
121 | case ".json":
122 | return json.MarshalIndent(config, "", " ")
123 | case ".yaml", ".yml":
124 | return yaml.Marshal(config)
125 | default:
126 | return nil, fmt.Errorf("unsupported file extension: %s", configPath)
127 | }
128 | }
129 |
130 | // unmarshalConfig unmarshals the config based on the file extension
131 | func unmarshalConfig[T any](data []byte, configPath string, config *T) error {
132 | switch filepath.Ext(configPath) {
133 | case ".json":
134 | return json.Unmarshal(data, config)
135 | case ".yaml", ".yml":
136 | return yaml.Unmarshal(data, config)
137 | default:
138 | return fmt.Errorf("unsupported file extension: %s", configPath)
139 | }
140 | }
141 |
142 | // ensureConfigDirExist ensures the config directory exists
143 | // If it does not exist, it will be created
144 | func ensureConfigDirExist() error {
145 | configDir, err := GetConfigDir()
146 | if err != nil {
147 | return err
148 | }
149 | if _, err := os.Stat(configDir); os.IsNotExist(err) {
150 | return os.MkdirAll(configDir, 0755)
151 | }
152 | return nil
153 | }
154 |
155 | // GetConfigDir returns the path to the config directory
156 | func GetConfigDir() (string, error) {
157 | configPath, err := xdg.ConfigFile(ConfigDir)
158 | if err != nil {
159 | log.Error().Err(err).Msg("Error while getting config path directory")
160 | return "", err
161 | }
162 | return configPath, nil
163 | }
164 |
165 | // ValidateConfigPath validates that a config file path is valid
166 | // so if parent directory exists, and if path is not 'ended' as directory
167 | func ValidateConfigPath(configPath string) error {
168 | if configPath == "" {
169 | return nil
170 | }
171 |
172 | fileInfo, err := os.Stat(configPath)
173 | if err != nil {
174 | if os.IsNotExist(err) {
175 | dir := filepath.Dir(configPath)
176 | if dir != "" && dir != "." {
177 | if _, dirErr := os.Stat(dir); dirErr != nil && os.IsNotExist(dirErr) {
178 | return fmt.Errorf("config directory does not exist: %s", dir)
179 | }
180 | }
181 | return nil
182 | }
183 | return fmt.Errorf("cannot access config file '%s': %w", configPath, err)
184 | }
185 |
186 | if fileInfo.IsDir() {
187 | return fmt.Errorf("config path is a directory, not a file: %s", configPath)
188 | }
189 |
190 | return nil
191 | }
192 |
193 | type MongoConfig struct {
194 | Host string
195 | Port string
196 | DB string
197 | Password string
198 | }
199 |
200 | func ParseMongoUri(uri string) (config MongoConfig, err error) {
201 | if !strings.HasPrefix(uri, "mongodb://") && !strings.HasPrefix(uri, "mongodb+srv://") {
202 | return MongoConfig{}, fmt.Errorf("invalid MongoDB URI prefix")
203 | }
204 |
205 | trimURI := strings.TrimPrefix(uri, "mongodb://")
206 | trimURI = strings.TrimPrefix(trimURI, "mongodb+srv://")
207 |
208 | splitURI := strings.Split(trimURI, "@")
209 | if len(splitURI) > 1 {
210 | // Extract credentials part
211 | credentials := strings.Split(splitURI[0], ":")
212 | if len(credentials) > 1 {
213 | config.Password = credentials[1]
214 | }
215 | trimURI = splitURI[1]
216 | } else {
217 | trimURI = splitURI[0]
218 | }
219 |
220 | if strings.Contains(trimURI, "?") {
221 | trimURI = strings.Split(trimURI, "?")[0]
222 | }
223 |
224 | splitDB := strings.Split(trimURI, "/")
225 | if len(splitDB) > 1 {
226 | config.DB = splitDB[1]
227 | trimURI = splitDB[0]
228 | }
229 |
230 | hosts := strings.Split(trimURI, ",")
231 | hostPort := strings.Split(hosts[0], ":")
232 |
233 | config.Host = hostPort[0]
234 | if len(hostPort) > 1 {
235 | config.Port = hostPort[1]
236 | } else {
237 | config.Port = "27017"
238 | }
239 |
240 | return config, nil
241 | }
242 |
--------------------------------------------------------------------------------
/internal/mongo/state_test.go:
--------------------------------------------------------------------------------
1 | package mongo
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | "go.mongodb.org/mongo-driver/bson/primitive"
8 | )
9 |
10 | func TestCollectionState_UpdateFilter(t *testing.T) {
11 | cs := &CollectionState{Filter: `{"old": "filter"}`, Skip: 5}
12 |
13 | cs.SetFilter(`{"new": "filter"}`)
14 | assert.Equal(t, `{"new": "filter"}`, cs.Filter)
15 | assert.Equal(t, int64(0), cs.Skip)
16 |
17 | cs.SetFilter(" ")
18 | assert.Equal(t, "", cs.Filter)
19 |
20 | cs.SetFilter("{}")
21 | assert.Equal(t, "", cs.Filter)
22 | }
23 |
24 | func TestCollectionState_UpdateSort(t *testing.T) {
25 | cs := &CollectionState{Sort: `{"old": 1}`}
26 |
27 | cs.SetSort(`{"new": -1}`)
28 | assert.Equal(t, `{"new": -1}`, cs.Sort)
29 |
30 | cs.SetSort(" ")
31 | assert.Equal(t, "", cs.Sort)
32 |
33 | cs.SetSort("{}")
34 | assert.Equal(t, "", cs.Sort)
35 | }
36 |
37 | func TestCollectionState_UpdateProjection(t *testing.T) {
38 | cs := &CollectionState{Projection: `{"old": 1}`}
39 |
40 | cs.SetProjection(`{"new": 1, "field": 0}`)
41 | assert.Equal(t, `{"new": 1, "field": 0}`, cs.Projection)
42 |
43 | cs.SetProjection(" ")
44 | assert.Equal(t, "", cs.Projection)
45 |
46 | cs.SetProjection("{}")
47 | assert.Equal(t, "", cs.Projection)
48 | }
49 |
50 | func TestCollectionState_GetDocById(t *testing.T) {
51 | cs := &CollectionState{
52 | docs: []primitive.M{
53 | {"_id": "1", "value": 1},
54 | },
55 | }
56 |
57 | doc := cs.GetDocById("1")
58 | assert.NotNil(t, doc)
59 | assert.Equal(t, "1", doc["_id"])
60 |
61 | doc = cs.GetDocById("2")
62 | assert.Nil(t, doc)
63 | }
64 |
65 | func TestCollectionState_PopulateDocs(t *testing.T) {
66 | cs := &CollectionState{}
67 | docs := []primitive.M{
68 | {"_id": "1", "value": 1},
69 | {"_id": "2", "value": 2},
70 | }
71 |
72 | cs.PopulateDocs(docs)
73 | assert.Len(t, cs.docs, 2)
74 | assert.Equal(t, primitive.M{"_id": "1", "value": 1}, cs.docs[0])
75 | assert.Equal(t, primitive.M{"_id": "2", "value": 2}, cs.docs[1])
76 | }
77 |
78 | func TestCollectionState_AppendDoc(t *testing.T) {
79 | cs := &CollectionState{Count: 1}
80 | doc := primitive.M{"_id": "1", "value": 1}
81 |
82 | cs.AppendDoc(doc)
83 | assert.Len(t, cs.docs, 1)
84 | assert.Equal(t, doc, cs.docs[0])
85 | assert.Equal(t, int64(2), cs.Count)
86 | }
87 |
88 | func TestCollectionState_DeleteDoc(t *testing.T) {
89 | cs := &CollectionState{
90 | docs: []primitive.M{{"_id": "1", "value": 1}},
91 | Count: 1,
92 | }
93 |
94 | cs.DeleteDoc("1")
95 | assert.Len(t, cs.docs, 0)
96 | assert.Equal(t, int64(0), cs.Count)
97 | }
98 |
99 | func TestCollectionState_GetJsonDocById_DoesNotModifyState(t *testing.T) {
100 | id1 := primitive.NewObjectID()
101 | id2 := primitive.NewObjectID()
102 | cs := &CollectionState{
103 | docs: []primitive.M{
104 | {"_id": id1, "value": 1},
105 | {"_id": id2, "value": 2},
106 | },
107 | }
108 |
109 | jsonDoc, err := cs.GetJsonDocById(id1)
110 | assert.NoError(t, err)
111 | assert.NotEmpty(t, jsonDoc)
112 |
113 | assert.Contains(t, jsonDoc, "$oid")
114 | assert.Contains(t, jsonDoc, id1.Hex())
115 |
116 | assert.Len(t, cs.docs, 2)
117 | assert.Equal(t, primitive.M{"_id": id1, "value": 1}, cs.docs[0])
118 | assert.Equal(t, primitive.M{"_id": id2, "value": 2}, cs.docs[1])
119 | }
120 |
121 | func TestCollectionState_GetDocById_WithBinaryId(t *testing.T) {
122 | binaryId := primitive.Binary{Data: []byte{1, 2, 3, 4}, Subtype: 0}
123 | cs := &CollectionState{
124 | docs: []primitive.M{
125 | {"_id": binaryId, "value": "binary_doc"},
126 | },
127 | }
128 |
129 | doc := cs.GetDocById(binaryId)
130 | assert.NotNil(t, doc)
131 | assert.Equal(t, binaryId, doc["_id"])
132 | assert.Equal(t, "binary_doc", doc["value"])
133 |
134 | differentBinaryId := primitive.Binary{Data: []byte{5, 6, 7, 8}, Subtype: 0}
135 | doc = cs.GetDocById(differentBinaryId)
136 | assert.Nil(t, doc)
137 | }
138 |
139 | func TestCollectionState_DeleteDoc_WithBinaryId(t *testing.T) {
140 | binaryId := primitive.Binary{Data: []byte{1, 2, 3, 4}, Subtype: 0}
141 | cs := &CollectionState{
142 | docs: []primitive.M{
143 | {"_id": binaryId, "value": "binary_doc"},
144 | },
145 | Count: 1,
146 | }
147 |
148 | cs.DeleteDoc(binaryId)
149 | assert.Len(t, cs.docs, 0)
150 | assert.Equal(t, int64(0), cs.Count)
151 | }
152 |
153 | func TestCollectionState_UpdateRawDoc_WithBinaryId(t *testing.T) {
154 | binaryId := primitive.Binary{Data: []byte{1, 2, 3, 4}, Subtype: 0}
155 | cs := &CollectionState{
156 | docs: []primitive.M{
157 | {"_id": binaryId, "value": "old_value"},
158 | },
159 | }
160 |
161 | updatedDoc := `{"_id": {"$binary": {"base64": "AQIDBA==", "subType": "00"}}, "value": "new_value"}`
162 | err := cs.UpdateRawDoc(updatedDoc)
163 | assert.NoError(t, err)
164 | assert.Len(t, cs.docs, 1)
165 | assert.Equal(t, "new_value", cs.docs[0]["value"])
166 | }
167 |
168 | func TestCollectionState_GetValueByIdAndColumn(t *testing.T) {
169 | cs := &CollectionState{
170 | docs: []primitive.M{
171 | {
172 | "_id": "1",
173 | "name": "John Doe",
174 | "age": int32(30),
175 | "active": true,
176 | "address": primitive.M{
177 | "city": "New York",
178 | "country": "USA",
179 | },
180 | },
181 | },
182 | }
183 |
184 | tests := []struct {
185 | name string
186 | id any
187 | column string
188 | expected string
189 | }{
190 | {
191 | name: "simple string field",
192 | id: "1",
193 | column: "name",
194 | expected: "John Doe",
195 | },
196 | {
197 | name: "simple int field",
198 | id: "1",
199 | column: "age",
200 | expected: "30",
201 | },
202 | {
203 | name: "simple bool field",
204 | id: "1",
205 | column: "active",
206 | expected: "true",
207 | },
208 | {
209 | name: "nested field column",
210 | id: "1",
211 | column: "address.city",
212 | expected: "New York",
213 | },
214 | {
215 | name: "nested field column deep",
216 | id: "1",
217 | column: "address.country",
218 | expected: "USA",
219 | },
220 | {
221 | name: "non-existent column",
222 | id: "1",
223 | column: "nonexistent",
224 | expected: "null",
225 | },
226 | {
227 | name: "non-existent id",
228 | id: "999",
229 | column: "name",
230 | expected: "",
231 | },
232 | }
233 |
234 | for _, tt := range tests {
235 | t.Run(tt.name, func(t *testing.T) {
236 | result, err := cs.GetValueByIdAndColumn(tt.id, tt.column)
237 | assert.NoError(t, err)
238 | assert.Equal(t, tt.expected, result)
239 | })
240 | }
241 | }
242 |
243 | func TestCollectionState_getFieldValue(t *testing.T) {
244 | doc := primitive.M{
245 | "name": "Jane Smith",
246 | "age": int64(25),
247 | "contact": primitive.M{
248 | "email": "jane@example.com",
249 | "phone": primitive.M{
250 | "mobile": "555-1234",
251 | "home": "555-5678",
252 | },
253 | },
254 | }
255 |
256 | cs := &CollectionState{}
257 |
258 | tests := []struct {
259 | name string
260 | fieldPath string
261 | expected any
262 | }{
263 | {
264 | name: "top level field",
265 | fieldPath: "name",
266 | expected: "Jane Smith",
267 | },
268 | {
269 | name: "nested field level 1",
270 | fieldPath: "contact.email",
271 | expected: "jane@example.com",
272 | },
273 | {
274 | name: "nested field level 2",
275 | fieldPath: "contact.phone.mobile",
276 | expected: "555-1234",
277 | },
278 | {
279 | name: "non-existent field",
280 | fieldPath: "address",
281 | expected: nil,
282 | },
283 | {
284 | name: "non-existent nested field",
285 | fieldPath: "contact.address.city",
286 | expected: nil,
287 | },
288 | }
289 |
290 | for _, tt := range tests {
291 | t.Run(tt.name, func(t *testing.T) {
292 | result := cs.getFieldValue(doc, tt.fieldPath)
293 | assert.Equal(t, tt.expected, result)
294 | })
295 | }
296 | }
297 |
--------------------------------------------------------------------------------