├── .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 | ![autocomplete](./assets/autocomplete.webp) 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 | --------------------------------------------------------------------------------