diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a314406..5d28818 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,7 +20,7 @@ jobs: go-version: '1.24' - name: Run golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v8 with: version: latest args: --timeout=5m diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68baa93..84efc7f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,16 +2,13 @@ name: build # This workflow runs if the CI workflow is successful on: - workflow_run: - workflows: ["CI"] # Keep consistent with actual CI workflow name - types: - - completed - branches: [master] - workflow_dispatch: + push: + branches: [ master ] + pull_request: + branches: [ master ] jobs: build: - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest steps: - name: Checkout code @@ -22,14 +19,12 @@ jobs: with: go-version: 1.24 + # Syncs dependencies and builds the project - name: Build run: make build - name: Run tests run: make test - - name: Install - run: make install - - name: Clean up run: make clean diff --git a/.gitignore b/.gitignore index 06f46bc..a7171f8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ go.work.sum .vscode/ /gitx -/gitx.exe \ No newline at end of file +/gitx.exe + +build/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..887f9b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# Go parameters +BINARY_NAME=gitx +CMD_PATH=./cmd/gitx +BUILD_DIR=./build + +# Default target executed when you run `make` +all: build + +# Syncs dependencies +sync: + @echo "Syncing dependencies..." + @go mod tidy + @echo "Dependencies synced." + +# Builds the binary +build: sync + @echo "Building the application..." + @mkdir -p $(BUILD_DIR) + @go build -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH) + @echo "Binary available at $(BUILD_DIR)/$(BINARY_NAME)" + +# Runs the application +run: build + @echo "Running $(BINARY_NAME)..." + @$(BUILD_DIR)/$(BINARY_NAME) + +# Runs all tests +test: + @echo "Running tests..." + @go test -v ./... + +# Runs golangci-lint +ci: + @echo "Running golangci-lint..." + @golangci-lint run + +# Installs the binary to /usr/local/bin +install: build + @echo "Installing $(BINARY_NAME)..." + @go install $(CMD_PATH) + @echo "$(BINARY_NAME) installed successfully" + +# Cleans the build artifacts +clean: + @echo "Cleaning up..." + @rm -rf $(BUILD_DIR) + @echo "Cleanup complete." + +#PHONY targets are not files +.PHONY: all sync build run test ci install clean diff --git a/cmd/gitx/main.go b/cmd/gitx/main.go index e58579e..8f1583f 100644 --- a/cmd/gitx/main.go +++ b/cmd/gitx/main.go @@ -1,14 +1,24 @@ package main import ( + "errors" + "fmt" "log" + tea "github.com/charmbracelet/bubbletea" "github.com/gitxtui/gitx/internal/tui" + zone "github.com/lrstanley/bubblezone" ) func main() { + zone.NewGlobal() + defer zone.Close() + app := tui.NewApp() if err := app.Run(); err != nil { - log.Fatalf("Error running application: %v", err) + if !errors.Is(err, tea.ErrProgramKilled) { + log.Fatalf("Error running application: %v", err) + } } + fmt.Println("Bye from gitx! :)") } diff --git a/go.mod b/go.mod index 880ee28..41eedd7 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,19 @@ go 1.24 toolchain go1.24.5 -require github.com/charmbracelet/lipgloss v1.1.0 +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.9.0 + github.com/lrstanley/bubblezone v1.0.0 +) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -19,7 +25,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect + golang.org/x/sync v0.16.0 // indirect ) require ( @@ -27,6 +33,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index d1646f6..8720fdc 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,31 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -35,11 +47,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/internal/git/branch.go b/internal/git/branch.go new file mode 100644 index 0000000..aad9e0f --- /dev/null +++ b/internal/git/branch.go @@ -0,0 +1,168 @@ +package git + +import ( + "fmt" + "strings" +) + +// Branch represents a git branch with its metadata. +type Branch struct { + Name string + IsCurrent bool + LastCommit string +} + +// GetBranches fetches all local branches, their last commit time, and sorts them. +func (g *GitCommands) GetBranches() ([]*Branch, error) { + // This format gives us: + format := "%(committerdate:relative)\t%(refname:short)\t%(HEAD)" + args := []string{"for-each-ref", "--sort=-committerdate", "refs/heads/", fmt.Sprintf("--format=%s", format)} + + cmd := ExecCommand("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) == 1 && lines[0] == "" { + return []*Branch{}, nil // No branches found + } + + var branches []*Branch + var currentBranch *Branch + + for _, line := range lines { + parts := strings.Split(line, "\t") + if len(parts) < 2 { + continue + } + + isCurrent := len(parts) == 3 && parts[2] == "*" + branch := &Branch{ + Name: parts[1], + IsCurrent: isCurrent, + LastCommit: formatRelativeDate(parts[0]), + } + + if isCurrent { + currentBranch = branch + } else { + branches = append(branches, branch) + } + } + + // Prepend the current branch to the list to ensure it's always at the top. + if currentBranch != nil { + branches = append([]*Branch{currentBranch}, branches...) + } + + return branches, nil +} + +// formatRelativeDate converts git's "X units ago" to a shorter format. +func formatRelativeDate(dateStr string) string { + parts := strings.Split(dateStr, " ") + if len(parts) < 2 { + return dateStr // Return original if format is unexpected + } + + val := parts[0] + unit := parts[1] + + switch { + case strings.HasPrefix(unit, "second"): + return val + "s" + case strings.HasPrefix(unit, "minute"): + return val + "m" + case strings.HasPrefix(unit, "hour"): + return val + "h" + case strings.HasPrefix(unit, "day"): + return val + "d" + case strings.HasPrefix(unit, "week"): + return val + "w" + case strings.HasPrefix(unit, "month"): + return val + "M" + case strings.HasPrefix(unit, "year"): + return val + "y" + default: + return dateStr + } +} + +// BranchOptions specifies the options for managing branches. +type BranchOptions struct { + Create bool + Delete bool + Name string +} + +// ManageBranch creates or deletes branches. +func (g *GitCommands) ManageBranch(options BranchOptions) (string, error) { + args := []string{"branch"} + + if options.Delete { + if options.Name == "" { + return "", fmt.Errorf("branch name is required for deletion") + } + args = append(args, "-d", options.Name) + } else if options.Create { + if options.Name == "" { + return "", fmt.Errorf("branch name is required for creation") + } + args = append(args, options.Name) + } + + cmd := ExecCommand("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("branch operation failed: %v", err) + } + + return string(output), nil +} + +// Checkout switches branches or restores working tree files. +func (g *GitCommands) Checkout(branchName string) (string, error) { + if branchName == "" { + return "", fmt.Errorf("branch name is required") + } + + cmd := ExecCommand("git", "checkout", branchName) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to checkout branch: %v", err) + } + + return string(output), nil +} + +// Switch switches to a specified branch. +func (g *GitCommands) Switch(branchName string) (string, error) { + if branchName == "" { + return "", fmt.Errorf("branch name is required") + } + + cmd := ExecCommand("git", "switch", branchName) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to switch branch: %v", err) + } + + return string(output), nil +} + +// RenameBranch renames a branch. +func (g *GitCommands) RenameBranch(oldName, newName string) (string, error) { + if oldName == "" || newName == "" { + return "", fmt.Errorf("both old and new branch names are required") + } + + cmd := ExecCommand("git", "branch", "-m", oldName, newName) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to rename branch: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/clone.go b/internal/git/clone.go new file mode 100644 index 0000000..0751b6c --- /dev/null +++ b/internal/git/clone.go @@ -0,0 +1,27 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// CloneRepository clones a repository from a given URL into a specified directory. +func (g *GitCommands) CloneRepository(repoURL, directory string) (string, error) { + if repoURL == "" { + return "", fmt.Errorf("repository URL is required") + } + + var cmd *exec.Cmd + if directory != "" { + cmd = exec.Command("git", "clone", repoURL, directory) + } else { + cmd = exec.Command("git", "clone", repoURL) + } + + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to clone repository: %v", err) + } + + return fmt.Sprintf("Successfully cloned repository: %s", repoURL), nil +} diff --git a/internal/git/commit.go b/internal/git/commit.go new file mode 100644 index 0000000..4327d68 --- /dev/null +++ b/internal/git/commit.go @@ -0,0 +1,52 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// CommitOptions specifies the options for the git commit command. +type CommitOptions struct { + Message string + Amend bool +} + +// Commit records changes to the repository. +func (g *GitCommands) Commit(options CommitOptions) (string, error) { + if options.Message == "" && !options.Amend { + return "", fmt.Errorf("commit message is required unless amending") + } + + args := []string{"commit"} + + if options.Amend { + args = append(args, "--amend") + } + + if options.Message != "" { + args = append(args, "-m", options.Message) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to commit changes: %v", err) + } + + return string(output), nil +} + +// ShowCommit shows the details of a specific commit. +func (g *GitCommands) ShowCommit(commitHash string) (string, error) { + if commitHash == "" { + commitHash = "HEAD" + } + + cmd := exec.Command("git", "show", "--color=always", commitHash) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to show commit: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 0000000..2382bda --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,49 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// DiffOptions specifies the options for the git diff command. +type DiffOptions struct { + Commit1 string + Commit2 string + Cached bool + Stat bool + Color bool +} + +// ShowDiff shows changes between commits, commit and working tree, etc. +func (g *GitCommands) ShowDiff(options DiffOptions) (string, error) { + args := []string{"diff"} + + if options.Color { + args = append(args, "--color=always") + } + if options.Cached { + args = append(args, "--cached") + } + if options.Stat { + args = append(args, "--stat") + } + + if options.Commit1 != "" || options.Commit2 != "" { + args = append(args, "--") + } + + if options.Commit1 != "" { + args = append(args, options.Commit1) + } + if options.Commit2 != "" { + args = append(args, options.Commit2) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to get diff: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/files.go b/internal/git/files.go new file mode 100644 index 0000000..6caf986 --- /dev/null +++ b/internal/git/files.go @@ -0,0 +1,32 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// ListFiles shows information about files in the index and the working tree. +func (g *GitCommands) ListFiles() (string, error) { + cmd := exec.Command("git", "ls-files") + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to list files: %v", err) + } + + return string(output), nil +} + +// BlameFile shows what revision and author last modified each line of a file. +func (g *GitCommands) BlameFile(filePath string) (string, error) { + if filePath == "" { + return "", fmt.Errorf("file path is required") + } + + cmd := exec.Command("git", "blame", filePath) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to blame file: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/git.go b/internal/git/git.go index 90a1d04..9083c00 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,661 +1,17 @@ package git import ( - "fmt" "os/exec" - "path/filepath" ) // ExecCommand is a variable that holds the exec.Command function // This allows it to be mocked in tests var ExecCommand = exec.Command +// GitCommands provides an interface to execute Git commands. type GitCommands struct{} +// NewGitCommands creates a new instance of GitCommands. func NewGitCommands() *GitCommands { return &GitCommands{} } - -func (g *GitCommands) InitRepository(path string) error { - if path == "" { - path = "." - } - - cmd := ExecCommand("git", "init", path) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to initialize repository: %v\nOutput: %s", err, output) - } - - absPath, _ := filepath.Abs(path) - fmt.Printf("Initialized empty Git repository in %s\n", absPath) - return nil -} - -func (g *GitCommands) CloneRepository(repoURL, directory string) error { - if repoURL == "" { - return fmt.Errorf("repository URL is required") - } - - var cmd *exec.Cmd - if directory != "" { - cmd = exec.Command("git", "clone", repoURL, directory) - } else { - cmd = exec.Command("git", "clone", repoURL) - } - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to clone repository: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully cloned repository: %s\n", repoURL) - return nil -} - -func (g *GitCommands) ShowStatus() error { - cmd := exec.Command("git", "status", "--porcelain") - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get status: %v", err) - } - - if len(output) == 0 { - fmt.Println("Working directory clean") - return nil - } - - cmd = exec.Command("git", "status") - output, err = cmd.Output() - if err != nil { - return fmt.Errorf("failed to get detailed status: %v", err) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) ShowLog(options LogOptions) error { - args := []string{"log"} - - if options.Oneline { - args = append(args, "--oneline") - } - if options.Graph { - args = append(args, "--graph") - } - if options.MaxCount > 0 { - args = append(args, fmt.Sprintf("-%d", options.MaxCount)) - } - - cmd := exec.Command("git", args...) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get log: %v", err) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) ShowDiff(options DiffOptions) error { - args := []string{"diff"} - - if options.Cached { - args = append(args, "--cached") - } - if options.Stat { - args = append(args, "--stat") - } - if options.Commit1 != "" { - args = append(args, options.Commit1) - } - if options.Commit2 != "" { - args = append(args, options.Commit2) - } - - cmd := exec.Command("git", args...) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get diff: %v", err) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) ShowCommit(commitHash string) error { - if commitHash == "" { - commitHash = "HEAD" - } - - cmd := exec.Command("git", "show", commitHash) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to show commit: %v", err) - } - - fmt.Print(string(output)) - return nil -} - -type LogOptions struct { - Oneline bool - Graph bool - MaxCount int -} - -type DiffOptions struct { - Commit1 string - Commit2 string - Cached bool - Stat bool -} - -func GetStatus() (string, error) { - cmd := exec.Command("git", "status") - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), err - } - return string(output), nil -} - -func (g *GitCommands) AddFiles(paths []string) error { - if len(paths) == 0 { - paths = []string{"."} - } - - args := append([]string{"add"}, paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to add files: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully added files to staging area\n") - return nil -} - -func (g *GitCommands) ResetFiles(paths []string) error { - if len(paths) == 0 { - return fmt.Errorf("at least one file path is required") - } - - args := append([]string{"reset"}, paths...) - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to unstage files: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully unstaged files\n") - return nil -} - -type CommitOptions struct { - Message string - Amend bool -} - -func (g *GitCommands) Commit(options CommitOptions) error { - if options.Message == "" && !options.Amend { - return fmt.Errorf("commit message is required unless amending") - } - - args := []string{"commit"} - - if options.Amend { - args = append(args, "--amend") - } - - if options.Message != "" { - args = append(args, "-m", options.Message) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to commit changes: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type BranchOptions struct { - Create bool - Delete bool - Name string -} - -func (g *GitCommands) ManageBranch(options BranchOptions) error { - args := []string{"branch"} - - if options.Delete { - if options.Name == "" { - return fmt.Errorf("branch name is required for deletion") - } - args = append(args, "-d", options.Name) - } else if options.Create { - if options.Name == "" { - return fmt.Errorf("branch name is required for creation") - } - args = append(args, options.Name) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("branch operation failed: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) Checkout(branchName string) error { - if branchName == "" { - return fmt.Errorf("branch name is required") - } - - cmd := exec.Command("git", "checkout", branchName) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to checkout branch: %v\nOutput: %s", err, output) - } - - fmt.Printf("Switched to branch '%s'\n", branchName) - return nil -} - -func (g *GitCommands) Switch(branchName string) error { - if branchName == "" { - return fmt.Errorf("branch name is required") - } - - cmd := exec.Command("git", "switch", branchName) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to switch branch: %v\nOutput: %s", err, output) - } - - fmt.Printf("Switched to branch '%s'\n", branchName) - return nil -} - -type MergeOptions struct { - BranchName string - NoFastForward bool - Message string -} - -func (g *GitCommands) Merge(options MergeOptions) error { - if options.BranchName == "" { - return fmt.Errorf("branch name is required") - } - - args := []string{"merge"} - - if options.NoFastForward { - args = append(args, "--no-ff") - } - - if options.Message != "" { - args = append(args, "-m", options.Message) - } - - args = append(args, options.BranchName) - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to merge branch: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type TagOptions struct { - Create bool - Delete bool - Name string - Message string - Commit string -} - -func (g *GitCommands) ManageTag(options TagOptions) error { - args := []string{"tag"} - - if options.Delete { - if options.Name == "" { - return fmt.Errorf("tag name is required for deletion") - } - args = append(args, "-d", options.Name) - } else if options.Create { - if options.Name == "" { - return fmt.Errorf("tag name is required for creation") - } - if options.Message != "" { - args = append(args, "-m", options.Message) - } - args = append(args, options.Name) - if options.Commit != "" { - args = append(args, options.Commit) - } - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("tag operation failed: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type RemoteOptions struct { - Add bool - Remove bool - Name string - URL string - Verbose bool -} - -func (g *GitCommands) ManageRemote(options RemoteOptions) error { - args := []string{"remote"} - - if options.Verbose { - args = append(args, "-v") - } - - if options.Add { - if options.Name == "" || options.URL == "" { - return fmt.Errorf("remote name and URL are required for adding") - } - args = append(args, "add", options.Name, options.URL) - } else if options.Remove { - if options.Name == "" { - return fmt.Errorf("remote name is required for removal") - } - args = append(args, "remove", options.Name) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("remote operation failed: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) Fetch(remote string, branch string) error { - args := []string{"fetch"} - - if remote != "" { - args = append(args, remote) - } - - if branch != "" { - args = append(args, branch) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to fetch: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type PullOptions struct { - Remote string - Branch string - Rebase bool -} - -func (g *GitCommands) Pull(options PullOptions) error { - args := []string{"pull"} - - if options.Rebase { - args = append(args, "--rebase") - } - - if options.Remote != "" { - args = append(args, options.Remote) - } - - if options.Branch != "" { - args = append(args, options.Branch) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to pull: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type PushOptions struct { - Remote string - Branch string - Force bool - SetUpstream bool - Tags bool -} - -func (g *GitCommands) Push(options PushOptions) error { - args := []string{"push"} - - if options.Force { - args = append(args, "--force") - } - - if options.SetUpstream { - args = append(args, "--set-upstream") - } - - if options.Tags { - args = append(args, "--tags") - } - - if options.Remote != "" { - args = append(args, options.Remote) - } - - if options.Branch != "" { - args = append(args, options.Branch) - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to push: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) RemoveFiles(paths []string, cached bool) error { - if len(paths) == 0 { - return fmt.Errorf("at least one file path is required") - } - - args := []string{"rm"} - - if cached { - args = append(args, "--cached") - } - - args = append(args, paths...) - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to remove files: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully removed files\n") - return nil -} - -func (g *GitCommands) MoveFile(source, destination string) error { - if source == "" || destination == "" { - return fmt.Errorf("source and destination paths are required") - } - - cmd := exec.Command("git", "mv", source, destination) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to move file: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully moved %s to %s\n", source, destination) - return nil -} - -type RestoreOptions struct { - Paths []string - Source string - Staged bool - WorkingDir bool -} - -func (g *GitCommands) Restore(options RestoreOptions) error { - if len(options.Paths) == 0 { - return fmt.Errorf("at least one file path is required") - } - - args := []string{"restore"} - - if options.Staged { - args = append(args, "--staged") - } - - if options.WorkingDir { - args = append(args, "--worktree") - } - - if options.Source != "" { - args = append(args, "--source", options.Source) - } - - args = append(args, options.Paths...) - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to restore files: %v\nOutput: %s", err, output) - } - - fmt.Printf("Successfully restored files\n") - return nil -} - -func (g *GitCommands) Revert(commitHash string) error { - if commitHash == "" { - return fmt.Errorf("commit hash is required") - } - - cmd := exec.Command("git", "revert", commitHash) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to revert commit: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -type StashOptions struct { - Push bool - Pop bool - Apply bool - List bool - Show bool - Drop bool - Message string - StashID string -} - -func (g *GitCommands) Stash(options StashOptions) error { - if !options.Push && !options.Pop && !options.Apply && !options.List && !options.Show && !options.Drop { - options.Push = true - } - - var args []string - - if options.Push { - args = []string{"stash", "push"} - if options.Message != "" { - args = append(args, "-m", options.Message) - } - } else if options.Pop { - args = []string{"stash", "pop"} - if options.StashID != "" { - args = append(args, options.StashID) - } - } else if options.Apply { - args = []string{"stash", "apply"} - if options.StashID != "" { - args = append(args, options.StashID) - } - } else if options.List { - args = []string{"stash", "list"} - } else if options.Show { - args = []string{"stash", "show"} - if options.StashID != "" { - args = append(args, options.StashID) - } - } else if options.Drop { - args = []string{"stash", "drop"} - if options.StashID != "" { - args = append(args, options.StashID) - } - } - - cmd := exec.Command("git", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("stash operation failed: %v\nOutput: %s", err, output) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) ListFiles() error { - cmd := exec.Command("git", "ls-files") - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to list files: %v", err) - } - - fmt.Print(string(output)) - return nil -} - -func (g *GitCommands) BlameFile(filePath string) error { - if filePath == "" { - return fmt.Errorf("file path is required") - } - - cmd := exec.Command("git", "blame", filePath) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to blame file: %v", err) - } - - fmt.Print(string(output)) - return nil -} diff --git a/internal/git/git_test.go b/internal/git/git_test.go index be5e25b..8130b88 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -2,66 +2,11 @@ package git import ( "os" - "os/exec" "path/filepath" "strings" "testing" ) -// setupTestRepo creates a new temporary directory, initializes a git repository, -// and returns a cleanup function to be deferred. -func setupTestRepo(t *testing.T) (string, func()) { - t.Helper() - tempDir, err := os.MkdirTemp("", "git-test-") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get current working directory: %v", err) - } - - if err := os.Chdir(tempDir); err != nil { - t.Fatalf("failed to change to temp dir: %v", err) - } - - g := NewGitCommands() - if err := g.InitRepository(""); err != nil { - t.Fatalf("failed to initialize git repository: %v", err) - } - - // Configure git user for commits - if err := runGitConfig(tempDir); err != nil { - t.Fatalf("failed to set git config: %v", err) - } - - cleanup := func() { - if err := os.Chdir(originalDir); err != nil { - t.Fatalf("failed to change back to original directory: %v", err) - } - if err := os.RemoveAll(tempDir); err != nil { - t.Logf("failed to remove temp dir: %v", err) - } - } - - return tempDir, cleanup -} - -// createAndCommitFile creates a file with content and commits it. -func createAndCommitFile(t *testing.T, g *GitCommands, filename, content, message string) { - t.Helper() - if err := os.WriteFile(filename, []byte(content), 0644); err != nil { - t.Fatalf("failed to create test file %s: %v", filename, err) - } - if err := g.AddFiles([]string{filename}); err != nil { - t.Fatalf("failed to add file %s: %v", filename, err) - } - if err := g.Commit(CommitOptions{Message: message}); err != nil { - t.Fatalf("failed to commit file %s: %v", filename, err) - } -} - func TestNewGitCommands(t *testing.T) { if g := NewGitCommands(); g == nil { t.Error("NewGitCommands() returned nil") @@ -94,24 +39,59 @@ func TestGitCommands_InitRepository(t *testing.T) { g := NewGitCommands() repoPath := "test-repo" - if err := g.InitRepository(repoPath); err != nil { + output, err := g.InitRepository(repoPath) + if err != nil { t.Fatalf("InitRepository() failed: %v", err) } + if !strings.Contains(output, "Initialized empty Git repository") { + t.Errorf("expected success message, got: %s", output) + } + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { t.Errorf("expected .git directory to be created at %s", repoPath) } } func TestGitCommands_CloneRepository(t *testing.T) { + // 1. Arrange: Set up a source "remote" repository + remotePath, cleanup := setupRemoteRepo(t) + defer cleanup() + + // Set up a destination directory for the clone + localPath, err := os.MkdirTemp("", "git-local-") + if err != nil { + t.Fatalf("failed to create local repo dir: %v", err) + } + defer func() { + if err := os.RemoveAll(localPath); err != nil { + t.Logf("failed to remove local repo dir: %v", err) + } + }() + + // 2. Act: Perform the clone g := NewGitCommands() - err := g.CloneRepository("invalid-url", "") + output, err := g.CloneRepository(remotePath, localPath) + if err != nil { + t.Fatalf("CloneRepository() failed: %v", err) + } + + // 3. Assert: Verify the results + if !strings.Contains(output, "Successfully cloned repository") { + t.Errorf("expected success message, got: %s", output) + } + if _, err := os.Stat(filepath.Join(localPath, ".git")); os.IsNotExist(err) { + t.Error("expected .git directory to exist in cloned repo") + } + if _, err := os.Stat(filepath.Join(localPath, "testfile.txt")); os.IsNotExist(err) { + t.Error("expected cloned file to exist in cloned repo") + } + + // Test failure case + _, err = g.CloneRepository("invalid-url", "") if err == nil { t.Error("CloneRepository() with invalid URL should have failed, but did not") } - if !strings.Contains(err.Error(), "failed to clone repository") { - t.Errorf("expected clone error, got: %v", err) - } } func TestGitCommands_Status(t *testing.T) { @@ -121,16 +101,24 @@ func TestGitCommands_Status(t *testing.T) { g := NewGitCommands() // Test on a clean repo - if err := g.ShowStatus(); err != nil { - t.Errorf("ShowStatus() on clean repo failed: %v", err) + status, err := g.GetStatus(StatusOptions{Porcelain: false}) + if err != nil { + t.Errorf("GetStatus() on clean repo failed: %v", err) + } + if !strings.Contains(status, "nothing to commit, working tree clean") { + t.Errorf("expected clean status, got: %s", status) } // Test with a new file if err := os.WriteFile("new-file.txt", []byte("content"), 0644); err != nil { t.Fatalf("failed to create test file: %v", err) } - if err := g.ShowStatus(); err != nil { - t.Errorf("ShowStatus() with new file failed: %v", err) + status, err = g.GetStatus(StatusOptions{Porcelain: true}) + if err != nil { + t.Errorf("GetStatus() with new file failed: %v", err) + } + if !strings.Contains(status, "?? new-file.txt") { + t.Errorf("expected untracked file status, got: %s", status) } } @@ -139,11 +127,16 @@ func TestGitCommands_Log(t *testing.T) { defer cleanup() g := NewGitCommands() - createAndCommitFile(t, g, "log-test.txt", "content", "Initial commit for log test") + commitMessage := "Initial commit for log test" + createAndCommitFile(t, g, "log-test.txt", "content", commitMessage) - if err := g.ShowLog(LogOptions{}); err != nil { + log, err := g.ShowLog(LogOptions{}) + if err != nil { t.Errorf("ShowLog() failed: %v", err) } + if !strings.Contains(log, commitMessage) { + t.Errorf("expected log to contain commit message, got: %s", log) + } } func TestGitCommands_Diff(t *testing.T) { @@ -158,9 +151,13 @@ func TestGitCommands_Diff(t *testing.T) { t.Fatalf("failed to modify test file: %v", err) } - if err := g.ShowDiff(DiffOptions{}); err != nil { + diff, err := g.ShowDiff(DiffOptions{}) + if err != nil { t.Errorf("ShowDiff() failed: %v", err) } + if !strings.Contains(diff, "+modified") { + t.Errorf("expected diff to show added line, got: %s", diff) + } } func TestGitCommands_Commit(t *testing.T) { @@ -170,7 +167,7 @@ func TestGitCommands_Commit(t *testing.T) { g := NewGitCommands() // Test empty commit message - if err := g.Commit(CommitOptions{}); err == nil { + if _, err := g.Commit(CommitOptions{}); err == nil { t.Error("Commit() with empty message should fail") } @@ -181,10 +178,10 @@ func TestGitCommands_Commit(t *testing.T) { if err := os.WriteFile("commit-test.txt", []byte("amended content"), 0644); err != nil { t.Fatalf("failed to amend test file: %v", err) } - if err := g.AddFiles([]string{"commit-test.txt"}); err != nil { + if _, err := g.AddFiles([]string{"commit-test.txt"}); err != nil { t.Fatalf("failed to add amended file: %v", err) } - if err := g.Commit(CommitOptions{Amend: true}); err != nil { + if _, err := g.Commit(CommitOptions{Amend: true, Message: "Amended commit"}); err != nil { t.Errorf("Commit() with amend failed: %v", err) } } @@ -199,26 +196,99 @@ func TestGitCommands_BranchAndCheckout(t *testing.T) { branchName := "feature-branch" // Create branch - if err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { t.Fatalf("ManageBranch() create failed: %v", err) } // Checkout branch - if err := g.Checkout(branchName); err != nil { + if _, err := g.Checkout(branchName); err != nil { t.Fatalf("Checkout() failed: %v", err) } // Switch back to main/master - if err := g.Switch("master"); err != nil { + if _, err := g.Switch("master"); err != nil { t.Fatalf("Switch() failed: %v", err) } // Delete branch - if err := g.ManageBranch(BranchOptions{Delete: true, Name: branchName}); err != nil { + if _, err := g.ManageBranch(BranchOptions{Delete: true, Name: branchName}); err != nil { t.Fatalf("ManageBranch() delete failed: %v", err) } } +func TestGitCommands_Merge(t *testing.T) { + // Setup: Create a repo with two branches and diverging commits + _, cleanup := setupTestRepo(t) + defer cleanup() + g := NewGitCommands() + + // Create and commit on master + createAndCommitFile(t, g, "master.txt", "master content", "master commit") + + // Create feature branch and commit + branchName := "feature" + if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if _, err := g.Checkout(branchName); err != nil { + t.Fatalf("failed to checkout branch: %v", err) + } + createAndCommitFile(t, g, "feature.txt", "feature content", "feature commit") + + // Switch back to master and make another commit + if _, err := g.Checkout("master"); err != nil { + t.Fatalf("failed to checkout master: %v", err) + } + createAndCommitFile(t, g, "master2.txt", "master2 content", "master2 commit") + + // Merge feature branch into master + output, err := g.Merge(MergeOptions{BranchName: branchName}) + if err != nil { + t.Fatalf("Merge() failed: %v\nOutput: %s", err, output) + } + if !strings.Contains(output, "Merge made by") && !strings.Contains(output, "Already up to date") && !strings.Contains(output, "Fast-forward") { + t.Errorf("expected merge output, got: %s", output) + } +} + +func TestGitCommands_Rebase(t *testing.T) { + // Setup: Create a repo with two branches and diverging commits + _, cleanup := setupTestRepo(t) + defer cleanup() + g := NewGitCommands() + + // Create and commit on master + createAndCommitFile(t, g, "master.txt", "master content", "master commit") + + // Create feature branch and commit + branchName := "feature" + if _, err := g.ManageBranch(BranchOptions{Create: true, Name: branchName}); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if _, err := g.Checkout(branchName); err != nil { + t.Fatalf("failed to checkout branch: %v", err) + } + createAndCommitFile(t, g, "feature.txt", "feature content", "feature commit") + + // Switch back to master and make another commit + if _, err := g.Checkout("master"); err != nil { + t.Fatalf("failed to checkout master: %v", err) + } + createAndCommitFile(t, g, "master2.txt", "master2 content", "master2 commit") + + // Switch to feature branch and rebase onto master + if _, err := g.Checkout(branchName); err != nil { + t.Fatalf("failed to checkout feature branch: %v", err) + } + output, err := g.Rebase(RebaseOptions{BranchName: "master"}) + if err != nil { + t.Fatalf("Rebase() failed: %v\nOutput: %s", err, output) + } + if !strings.Contains(output, "Successfully rebased") && !strings.Contains(output, "Fast-forwarded") && !strings.Contains(output, "Applying") { + t.Errorf("expected rebase output, got: %s", output) + } +} + func TestGitCommands_FileOperations(t *testing.T) { _, cleanup := setupTestRepo(t) defer cleanup() @@ -230,17 +300,17 @@ func TestGitCommands_FileOperations(t *testing.T) { if err := os.WriteFile("new-file.txt", []byte("new"), 0644); err != nil { t.Fatalf("failed to create new file: %v", err) } - if err := g.AddFiles([]string{"new-file.txt"}); err != nil { + if _, err := g.AddFiles([]string{"new-file.txt"}); err != nil { t.Errorf("AddFiles() failed: %v", err) } // Test Reset - if err := g.ResetFiles([]string{"new-file.txt"}); err != nil { + if _, err := g.ResetFiles([]string{"new-file.txt"}); err != nil { t.Errorf("ResetFiles() failed: %v", err) } // Test Remove - if err := g.RemoveFiles([]string{"file-ops.txt"}, false); err != nil { + if _, err := g.RemoveFiles([]string{"file-ops.txt"}, false); err != nil { t.Errorf("RemoveFiles() failed: %v", err) } if _, err := os.Stat("file-ops.txt"); !os.IsNotExist(err) { @@ -249,7 +319,7 @@ func TestGitCommands_FileOperations(t *testing.T) { // Test Move createAndCommitFile(t, g, "source.txt", "move content", "Commit for move test") - if err := g.MoveFile("source.txt", "destination.txt"); err != nil { + if _, err := g.MoveFile("source.txt", "destination.txt"); err != nil { t.Errorf("MoveFile() failed: %v", err) } if _, err := os.Stat("destination.txt"); os.IsNotExist(err) { @@ -270,24 +340,12 @@ func TestGitCommands_Stash(t *testing.T) { } // Stash push - if err := g.Stash(StashOptions{Push: true, Message: "test stash"}); err != nil { + if _, err := g.Stash(StashOptions{Push: true, Message: "test stash"}); err != nil { t.Fatalf("Stash() push failed: %v", err) } // Stash apply - if err := g.Stash(StashOptions{Apply: true}); err != nil { + if _, err := g.Stash(StashOptions{Apply: true}); err != nil { t.Errorf("Stash() apply failed: %v", err) } } - -// Helper function to set git config for tests -func runGitConfig(dir string) error { - cmd := exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = dir - if err := cmd.Run(); err != nil { - return err - } - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = dir - return cmd.Run() -} diff --git a/internal/git/innit.go b/internal/git/innit.go new file mode 100644 index 0000000..7a823c8 --- /dev/null +++ b/internal/git/innit.go @@ -0,0 +1,22 @@ +package git + +import ( + "fmt" + "path/filepath" +) + +// InitRepository initializes a new Git repository in the specified path. +func (g *GitCommands) InitRepository(path string) (string, error) { + if path == "" { + path = "." + } + + cmd := ExecCommand("git", "init", path) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to initialize repository: %v", err) + } + + absPath, _ := filepath.Abs(path) + return fmt.Sprintf("Initialized empty Git repository in %s", absPath), nil +} diff --git a/internal/git/log.go b/internal/git/log.go new file mode 100644 index 0000000..a7755a9 --- /dev/null +++ b/internal/git/log.go @@ -0,0 +1,144 @@ +// Package git provides a wrapper around common git commands. +package git + +import ( + "fmt" + "strings" + "unicode" +) + +// CommitLog represents a single entry in the git log graph. +// It can be a commit or a line representing the graph structure. +type CommitLog struct { + Graph string // The graph structure string. + SHA string // The abbreviated commit hash. + AuthorInitials string // The initials of the commit author. + Subject string // The subject line of the commit message. +} + +// LogOptions specifies the options for the git log command. +type LogOptions struct { + Oneline bool + Graph bool + All bool + MaxCount int + Format string + Color string + Branch string +} + +// GetCommitLogsGraph fetches the git log with a graph format and returns it as a +// slice of CommitLog structs. +func (g *GitCommands) GetCommitLogsGraph() ([]CommitLog, error) { + // A custom format with a unique delimiter is used to reliably parse the output. + format := "%h|%an|%s" + options := LogOptions{ + Graph: true, + Format: format, + Color: "always", + All: true, + } + + output, err := g.ShowLog(options) + if err != nil { + return nil, err + } + return parseCommitLogs(strings.TrimSpace(output)), nil +} + +// ShowLog executes the `git log` command with the given options and returns the raw output. +func (g *GitCommands) ShowLog(options LogOptions) (string, error) { + args := []string{"log"} + + if options.Format != "" { + args = append(args, fmt.Sprintf("--pretty=format:%s", options.Format)) + } else if options.Oneline { + args = append(args, "--oneline") + } + + if options.Graph { + args = append(args, "--graph") + } + if options.All { + args = append(args, "--all") + } + if options.MaxCount > 0 { + args = append(args, fmt.Sprintf("-%d", options.MaxCount)) + } + if options.Color != "" { + args = append(args, fmt.Sprintf("--color=%s", options.Color)) + } + if options.Branch != "" { + args = append(args, options.Branch) + } + + cmd := ExecCommand("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to get log: %w", err) + } + + return string(output), nil +} + +// parseCommitLogs processes the raw git log string into a slice of CommitLog structs. +func parseCommitLogs(output string) []CommitLog { + var logs []CommitLog + lines := strings.Split(output, "\n") + + for _, line := range lines { + lineWithNodeReplaced := strings.ReplaceAll(line, "*", "○") + + if strings.Contains(lineWithNodeReplaced, "") { + parts := strings.SplitN(lineWithNodeReplaced, "", 2) + graph := parts[0] + commitData := strings.SplitN(parts[1], "|", 3) + + if len(commitData) == 3 { + logs = append(logs, CommitLog{ + Graph: graph, + SHA: commitData[0], + AuthorInitials: getInitials(commitData[1]), + Subject: commitData[2], + }) + } + } else { + logs = append(logs, CommitLog{Graph: lineWithNodeReplaced}) + } + } + return logs +} + +// getInitials extracts up to two initials from a name string for concise display. +func getInitials(name string) string { + name = strings.TrimSpace(name) + if len(name) == 0 { + return "" + } + + parts := strings.Fields(name) + if len(parts) > 1 { + // For "John Doe", return "JD". + return strings.ToUpper(string(parts[0][0]) + string(parts[len(parts)-1][0])) + } + + // For a single name like "John", return "JO". + var initials []rune + for _, r := range name { + if unicode.IsLetter(r) { + initials = append(initials, unicode.ToUpper(r)) + } + if len(initials) == 2 { + break + } + } + + if len(initials) == 1 { + return string(initials[0]) + } + if len(initials) > 1 { + return string(initials[0:2]) + } + + return "" +} diff --git a/internal/git/merge.go b/internal/git/merge.go new file mode 100644 index 0000000..532930d --- /dev/null +++ b/internal/git/merge.go @@ -0,0 +1,74 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// MergeOptions specifies the options for the git merge command. +type MergeOptions struct { + BranchName string + NoFastForward bool + Message string +} + +// Merge joins two or more development histories together. +func (g *GitCommands) Merge(options MergeOptions) (string, error) { + if options.BranchName == "" { + return "", fmt.Errorf("branch name is required") + } + + args := []string{"merge"} + + if options.NoFastForward { + args = append(args, "--no-ff") + } + + if options.Message != "" { + args = append(args, "-m", options.Message) + } + + args = append(args, options.BranchName) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to merge branch: %v", err) + } + + return string(output), nil +} + +// RebaseOptions specifies the options for the git rebase command. +type RebaseOptions struct { + BranchName string + Interactive bool + Abort bool + Continue bool +} + +// Rebase integrates changes from another branch. +func (g *GitCommands) Rebase(options RebaseOptions) (string, error) { + args := []string{"rebase"} + + if options.Interactive { + args = append(args, "-i") + } + if options.Abort { + args = append(args, "--abort") + } + if options.Continue { + args = append(args, "--continue") + } + if options.BranchName != "" && !options.Abort && !options.Continue { + args = append(args, options.BranchName) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to rebase branch: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/remote.go b/internal/git/remote.go new file mode 100644 index 0000000..44ab925 --- /dev/null +++ b/internal/git/remote.go @@ -0,0 +1,139 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// RemoteOptions specifies the options for managing remotes. +type RemoteOptions struct { + Add bool + Remove bool + Name string + URL string + Verbose bool +} + +// ManageRemote manages the set of repositories ("remotes") whose branches you track. +func (g *GitCommands) ManageRemote(options RemoteOptions) (string, error) { + args := []string{"remote"} + + if options.Verbose { + args = append(args, "-v") + } + + if options.Add { + if options.Name == "" || options.URL == "" { + return "", fmt.Errorf("remote name and URL are required for adding") + } + args = append(args, "add", options.Name, options.URL) + } else if options.Remove { + if options.Name == "" { + return "", fmt.Errorf("remote name is required for removal") + } + args = append(args, "remove", options.Name) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("remote operation failed: %v", err) + } + + return string(output), nil +} + +// Fetch downloads objects and refs from another repository. +func (g *GitCommands) Fetch(remote string, branch string) (string, error) { + args := []string{"fetch"} + + if remote != "" { + args = append(args, remote) + } + + if branch != "" { + args = append(args, branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to fetch: %v", err) + } + + return string(output), nil +} + +// PullOptions specifies the options for the git pull command. +type PullOptions struct { + Remote string + Branch string + Rebase bool +} + +// Pull fetches from and integrates with another repository or a local branch. +func (g *GitCommands) Pull(options PullOptions) (string, error) { + args := []string{"pull"} + + if options.Rebase { + args = append(args, "--rebase") + } + + if options.Remote != "" { + args = append(args, options.Remote) + } + + if options.Branch != "" { + args = append(args, options.Branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to pull: %v", err) + } + + return string(output), nil +} + +// PushOptions specifies the options for the git push command. +type PushOptions struct { + Remote string + Branch string + Force bool + SetUpstream bool + Tags bool +} + +// Push updates remote refs along with associated objects. +func (g *GitCommands) Push(options PushOptions) (string, error) { + args := []string{"push"} + + if options.Force { + args = append(args, "--force") + } + + if options.SetUpstream { + args = append(args, "--set-upstream") + } + + if options.Tags { + args = append(args, "--tags") + } + + if options.Remote != "" { + args = append(args, options.Remote) + } + + if options.Branch != "" { + args = append(args, options.Branch) + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to push: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/repo.go b/internal/git/repo.go new file mode 100644 index 0000000..ddd3561 --- /dev/null +++ b/internal/git/repo.go @@ -0,0 +1,46 @@ +package git + +import ( + "path/filepath" + "strings" +) + +// GetRepoInfo returns the current repository and active branch name. +func (g *GitCommands) GetRepoInfo() (repoName string, branchName string, err error) { + // Get the root dir of the repo. + repoPathBytes, err := ExecCommand("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", "", err + } + repoPath := strings.TrimSpace(string(repoPathBytes)) + + repoName = filepath.Base(repoPath) + + // Get the current branch name. + repoBranchBytes, err := ExecCommand("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return "", "", err + } + branchName = strings.TrimSpace(string(repoBranchBytes)) + + return repoName, branchName, nil +} + +func (g *GitCommands) GetGitRepoPath() (repoPath string, err error) { + repoPathBytes, err := ExecCommand("git", "rev-parse", "--git-dir").Output() + if err != nil { + return "", err + } + repoPath = strings.TrimSpace(string(repoPathBytes)) + return repoPath, nil +} + +// GetUserName returns the user's name from the git config. +func (g *GitCommands) GetUserName() (string, error) { + cmd := ExecCommand("git", "config", "user.name") + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} diff --git a/internal/git/repo_test.go b/internal/git/repo_test.go new file mode 100644 index 0000000..90275f2 --- /dev/null +++ b/internal/git/repo_test.go @@ -0,0 +1,36 @@ +package git + +import ( + "strings" + "testing" +) + +func TestGetRepoInfo(t *testing.T) { + g := NewGitCommands() + + // This test will only pass if run inside a git repository. + repoName, branchName, err := g.GetRepoInfo() + + if err != nil { + t.Fatalf("GetRepoInfo() returned an error: %v", err) + } + + if repoName == "" { + t.Error("Expected repoName to not be empty") + } + + if branchName == "" { + t.Error("Expected branchName to not be empty") + } + + // Verify with actual git commands + expectedBranchBytes, err := ExecCommand("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + t.Fatalf("Failed to get branch name from git: %v", err) + } + expectedBranch := strings.TrimSpace(string(expectedBranchBytes)) + + if branchName != expectedBranch { + t.Errorf("got branch %q, want %q", branchName, expectedBranch) + } +} diff --git a/internal/git/stage.go b/internal/git/stage.go new file mode 100644 index 0000000..3f5e034 --- /dev/null +++ b/internal/git/stage.go @@ -0,0 +1,130 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// AddFiles adds file contents to the index (staging area). +func (g *GitCommands) AddFiles(paths []string) (string, error) { + if len(paths) == 0 { + paths = []string{"."} + } + + args := append([]string{"add"}, paths...) + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to add files: %v", err) + } + + return string(output), nil +} + +// ResetFiles resets the current HEAD to the specified state, unstaging files. +func (g *GitCommands) ResetFiles(paths []string) (string, error) { + if len(paths) == 0 { + return "", fmt.Errorf("at least one file path is required") + } + + args := append([]string{"reset"}, paths...) + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to unstage files: %v", err) + } + + return string(output), nil +} + +// RemoveFiles removes files from the working tree and from the index. +func (g *GitCommands) RemoveFiles(paths []string, cached bool) (string, error) { + if len(paths) == 0 { + return "", fmt.Errorf("at least one file path is required") + } + + args := []string{"rm"} + + if cached { + args = append(args, "--cached") + } + + args = append(args, paths...) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to remove files: %v", err) + } + + return string(output), nil +} + +// MoveFile moves or renames a file, a directory, or a symlink. +func (g *GitCommands) MoveFile(source, destination string) (string, error) { + if source == "" || destination == "" { + return "", fmt.Errorf("source and destination paths are required") + } + + cmd := exec.Command("git", "mv", source, destination) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to move file: %v", err) + } + + return string(output), nil +} + +// RestoreOptions specifies the options for the git restore command. +type RestoreOptions struct { + Paths []string + Source string + Staged bool + WorkingDir bool +} + +// Restore restores working tree files. +func (g *GitCommands) Restore(options RestoreOptions) (string, error) { + if len(options.Paths) == 0 { + return "", fmt.Errorf("at least one file path is required") + } + + args := []string{"restore"} + + if options.Staged { + args = append(args, "--staged") + } + + if options.WorkingDir { + args = append(args, "--worktree") + } + + if options.Source != "" { + args = append(args, "--source", options.Source) + } + + args = append(args, options.Paths...) + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to restore files: %v", err) + } + + return string(output), nil +} + +// Revert is used to record some new commits to reverse the effect of some earlier commits. +func (g *GitCommands) Revert(commitHash string) (string, error) { + if commitHash == "" { + return "", fmt.Errorf("commit hash is required") + } + + cmd := exec.Command("git", "revert", commitHash) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("failed to revert commit: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/stash.go b/internal/git/stash.go new file mode 100644 index 0000000..6b743fb --- /dev/null +++ b/internal/git/stash.go @@ -0,0 +1,109 @@ +package git + +import ( + "fmt" + "os/exec" + "strings" +) + +// Stash represents a single entry in the git stash list. +type Stash struct { + Name string + Branch string + Message string +} + +// GetStashes fetches all stashes and returns them as a slice of Stash structs. +func (g *GitCommands) GetStashes() ([]*Stash, error) { + // Format: stash@{0} + // Branch: On master + // Message: WIP on master: 52f3a6b feat: add panels + // We use a unique delimiter to reliably parse the multi-line output for each stash. + format := "%gD%n%gs" + cmd := ExecCommand("git", "stash", "list", fmt.Sprintf("--format=%s", format)) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, err + } + + rawStashes := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(rawStashes) == 1 && rawStashes[0] == "" { + return []*Stash{}, nil // No stashes found + } + + var stashes []*Stash + for i, rawStash := range rawStashes { + parts := strings.SplitN(rawStash, ": ", 2) + if len(parts) < 2 { + continue // Malformed entry + } + stashes = append(stashes, &Stash{ + Name: fmt.Sprintf("stash@{%d}", i), + Branch: parts[0], + Message: parts[1], + }) + } + return stashes, nil +} + +// StashOptions specifies the options for the git stash command. +type StashOptions struct { + Push bool + Pop bool + Apply bool + List bool + Show bool + Drop bool + Message string + StashID string +} + +// Stash saves your local modifications away and reverts the working directory to match the HEAD commit. +func (g *GitCommands) Stash(options StashOptions) (string, error) { + if !options.Push && !options.Pop && !options.Apply && !options.List && !options.Show && !options.Drop { + options.Push = true + } + + var args []string + + if options.Push { + args = []string{"stash", "push"} + if options.Message != "" { + args = append(args, "-m", options.Message) + } + } else if options.Pop { + args = []string{"stash", "pop"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.Apply { + args = []string{"stash", "apply"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.List { + args = []string{"stash", "list"} + } else if options.Show { + args = []string{"stash", "show", "--color=always"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } else if options.Drop { + args = []string{"stash", "drop"} + if options.StashID != "" { + args = append(args, options.StashID) + } + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + // The command fails if there's no stash. + if strings.Contains(string(output), "No stash entries found") || strings.Contains(string(output), "No stash found") { + return "No stashes found.", nil + } + return string(output), fmt.Errorf("stash operation failed: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/status.go b/internal/git/status.go new file mode 100644 index 0000000..d7517fe --- /dev/null +++ b/internal/git/status.go @@ -0,0 +1,20 @@ +package git + +// StatusOptions specifies arguments for git status command. +type StatusOptions struct { + Porcelain bool +} + +// GetStatus retrieves the git status and returns it as a string. +func (g *GitCommands) GetStatus(options StatusOptions) (string, error) { + args := []string{"status"} + if options.Porcelain { + args = append(args, "--porcelain") + } + cmd := ExecCommand("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), err + } + return string(output), nil +} diff --git a/internal/git/tag.go b/internal/git/tag.go new file mode 100644 index 0000000..f60fc32 --- /dev/null +++ b/internal/git/tag.go @@ -0,0 +1,46 @@ +package git + +import ( + "fmt" + "os/exec" +) + +// TagOptions specifies the options for managing tags. +type TagOptions struct { + Create bool + Delete bool + Name string + Message string + Commit string +} + +// ManageTag creates, lists, deletes or verifies a tag object signed with GPG. +func (g *GitCommands) ManageTag(options TagOptions) (string, error) { + args := []string{"tag"} + + if options.Delete { + if options.Name == "" { + return "", fmt.Errorf("tag name is required for deletion") + } + args = append(args, "-d", options.Name) + } else if options.Create { + if options.Name == "" { + return "", fmt.Errorf("tag name is required for creation") + } + if options.Message != "" { + args = append(args, "-m", options.Message) + } + args = append(args, options.Name) + if options.Commit != "" { + args = append(args, options.Commit) + } + } + + cmd := exec.Command("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("tag operation failed: %v", err) + } + + return string(output), nil +} diff --git a/internal/git/testing.go b/internal/git/testing.go new file mode 100644 index 0000000..e2710cb --- /dev/null +++ b/internal/git/testing.go @@ -0,0 +1,150 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// setupTestRepo creates a new temporary directory, initializes a git repository, +// and returns a cleanup function to be deferred. +func setupTestRepo(t *testing.T) (string, func()) { + t.Helper() + tempDir, err := os.MkdirTemp("", "git-test-") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + // Create a temporary home directory to isolate from global git config + tempHome, err := os.MkdirTemp("", "git-home-") + if err != nil { + t.Fatalf("failed to create temp home dir: %v", err) + } + + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", tempHome); err != nil { + t.Fatalf("failed to set HOME env var: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("failed to change to temp dir: %v", err) + } + + g := NewGitCommands() + if _, err := g.InitRepository(""); err != nil { + t.Fatalf("failed to initialize git repository: %v", err) + } + + // Configure git user for commits + if err := runGitConfig(tempDir); err != nil { + t.Fatalf("failed to set git config: %v", err) + } + + // Create an initial commit to make it a "clean" repo with history. + if err := os.WriteFile("initial.txt", []byte("initial content"), 0644); err != nil { + t.Fatalf("failed to create initial file: %v", err) + } + if _, err := g.AddFiles([]string{"initial.txt"}); err != nil { + t.Fatalf("failed to add initial file: %v", err) + } + if _, err := g.Commit(CommitOptions{Message: "Initial commit"}); err != nil { + t.Fatalf("failed to create initial commit: %v", err) + } + + cleanup := func() { + if err := os.Chdir(originalDir); err != nil { + t.Fatalf("failed to change back to original directory: %v", err) + } + if err := os.RemoveAll(tempDir); err != nil { + t.Logf("failed to remove temp dir: %v", err) + } + if err := os.RemoveAll(tempHome); err != nil { + t.Logf("failed to remove temp home dir: %v", err) + } + if err := os.Setenv("HOME", originalHome); err != nil { + t.Logf("failed to restore HOME env var: %v", err) + } + } + + return tempDir, cleanup +} + +// setupRemoteRepo creates a temporary directory, initializes a git repository in it, +// and commits a file. It returns the path to the repo and a cleanup function. +func setupRemoteRepo(t *testing.T) (string, func()) { + t.Helper() + remotePath, err := os.MkdirTemp("", "git-remote-") + if err != nil { + t.Fatalf("failed to create remote repo dir: %v", err) + } + + // Initialize the remote repo + cmd := exec.Command("git", "init") + cmd.Dir = remotePath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to init remote repo: %v", err) + } + + // Create a file and commit it to the remote + if err := runGitConfig(remotePath); err != nil { + t.Fatalf("failed to set git config on remote: %v", err) + } + if err := os.WriteFile(filepath.Join(remotePath, "testfile.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("failed to create file in remote: %v", err) + } + cmd = exec.Command("git", "add", ".") + cmd.Dir = remotePath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to add file in remote: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "initial commit") + cmd.Dir = remotePath + if err := cmd.Run(); err != nil { + t.Fatalf("failed to commit in remote: %v", err) + } + + cleanup := func() { + if err := os.RemoveAll(remotePath); err != nil { + t.Logf("failed to remove remote repo dir: %v", err) + } + } + return remotePath, cleanup +} + +// createAndCommitFile creates a file with content and commits it. +func createAndCommitFile(t *testing.T, g *GitCommands, filename, content, message string) { + t.Helper() + if err := os.WriteFile(filename, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file %s: %v", filename, err) + } + if _, err := g.AddFiles([]string{filename}); err != nil { + t.Fatalf("failed to add file %s: %v", filename, err) + } + if _, err := g.Commit(CommitOptions{Message: message}); err != nil { + t.Fatalf("failed to commit file %s: %v", filename, err) + } +} + +// Helper function to set git config for tests +func runGitConfig(dir string) error { + cmd := exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + return err + } + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + return err + } + // Disable GPG signing for commits + cmd = exec.Command("git", "config", "commit.gpgsign", "false") + cmd.Dir = dir + return cmd.Run() +} diff --git a/internal/tui/constants.go b/internal/tui/constants.go new file mode 100644 index 0000000..fa7e563 --- /dev/null +++ b/internal/tui/constants.go @@ -0,0 +1,105 @@ +package tui + +import "time" + +const ( + // --- Layout Ratios --- + // leftPanelWidthRatio defines the percentage of the total width for the left column. + // rightPanelWidthRatio is "1 - leftPanelWidthRatio". + leftPanelWidthRatio = 0.35 + // helpViewWidthRatio defines the width of the help view relative to the total width. + helpViewWidthRatio = 0.5 + // helpViewHeightRatio defines the height of the help view relative to the total height. + helpViewHeightRatio = 0.75 + // expandedPanelHeightRatio defines the height of a focused panel relative to the total height. + expandedPanelHeightRatio = 0.4 + + // --- Layout Dimensions --- + // collapsedPanelHeight is the fixed height for a panel when it's not in focus. + collapsedPanelHeight = 3 + // borderWidth is the horizontal space taken by left and right borders. + borderWidth = 2 + // titleBarHeight is the vertical space taken by top and bottom borders with titles. + titleBarHeight = 2 + // statusPanelHeight is the fixed height for the status panel. + statusPanelHeight = 3 + + // --- Help View Styling --- + // helpTitleMargin is the left margin for the title in the help view. + helpTitleMargin = 9 + // helpKeyWidth is the fixed width for the keybinding column in the help view. + helpKeyWidth = 12 + // helpDescMargin is the right margin for the keybinding column in the help view. + helpDescMargin = 1 + + // --- Characters & Symbols --- + scrollThumbChar = "▐" + graphNodeChar = "○" + dirExpandedIcon = "▼ " + repoRootNodeName = "." + gitRenameDelimiter = " -> " + initialContentLoading = "Loading..." + + // --- File Watcher --- + // fileWatcherPollInterval is the debounce interval for repository file system events. + fileWatcherPollInterval = 500 * time.Millisecond + + // --- Git Status Parsing --- + // porcelainStatusPrefixLength is the length of the status prefix in `git status --porcelain`. + porcelainStatusPrefixLength = 3 +) + +// --- Border Characters --- +const ( + borderTop = "─" + borderBottom = "─" + borderLeft = "│" + borderRight = "│" + borderTopLeft = "╭" + borderTopRight = "╮" + borderBottomLeft = "╰" + borderBottomRight = "╯" +) + +// --- Tree Characters --- +const ( + treeConnector = "" + treeConnectorLast = "" + treePrefix = " " + treePrefixLast = " " +) + +// --- Hyperlink URLs --- +const ( + githubMainPage = "https://github.com/gitxtui/gitx" + docsPage = "https://gitxtui.github.io/docs/" +) + +const ( + asciiArt = ` + WELCOME TO + + ██████╗ ██╗████████╗██╗ ██╗ + ██╔════╝ ██║╚══██╔══╝╚██╗██╔╝ + ██║ ███╗██║ ██║ ╚███╔╝ + ██║ ██║██║ ██║ ██╔██╗ + ╚██████╔╝██║ ██║ ██╔╝ ██╗ + ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ + + %s +` + welcomeMsg = ` + ○ User: %s + Welcome to GITx, your terminal-based Git helper, inspired by lazygit. + Here is a great tutorial to learn about git: %s +` + + // asciiArt alternative, named "_" to remove unused warnings + _ = ` + ▗▄▄▖▗▄▄▄▖▗▄▄▄▖ + ▐▌ █ █ ▄ ▄ + ▐▌▝▜▌ █ █ ▀▄▀ + ▝▚▄▞▘▗▄█▄▖ █ ▄▀ ▀▄ + +` +) diff --git a/internal/tui/filetree.go b/internal/tui/filetree.go new file mode 100644 index 0000000..1b871ee --- /dev/null +++ b/internal/tui/filetree.go @@ -0,0 +1,152 @@ +package tui + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// Node represents a file or directory within the file tree structure. +type Node struct { + name string + status string // Git status prefix (e.g., "M ", "??"), only for file nodes. + path string // Full path relative to the repository root. + isRenamed bool + children []*Node +} + +// BuildTree parses the output of `git status --porcelain` to construct a file tree. +func BuildTree(gitStatus string) *Node { + root := &Node{name: repoRootNodeName, path: "."} + + lines := strings.Split(gitStatus, "\n") + if len(lines) == 1 && lines[0] == "" { + return root + } + + for _, line := range lines { + if len(line) < porcelainStatusPrefixLength { + continue + } + status := line[:2] + fullPath := strings.TrimSpace(line[porcelainStatusPrefixLength:]) + isRenamed := false + + if status[0] == 'R' || status[0] == 'C' { + parts := strings.Split(fullPath, gitRenameDelimiter) + if len(parts) == 2 { + fullPath = parts[1] + isRenamed = true + } + } + + parts := strings.Split(fullPath, string(filepath.Separator)) + currentNode := root + for i, part := range parts { + childNode := currentNode.findChild(part) + if childNode == nil { + // Construct path for the new node based on its parent + nodePath := filepath.Join(currentNode.path, part) + if currentNode.path == "." { + nodePath = part + } + childNode = &Node{name: part, path: nodePath} + currentNode.children = append(currentNode.children, childNode) + } + currentNode = childNode + + if i == len(parts)-1 { // Leaf node (file) + currentNode.status = status + currentNode.path = fullPath // Overwrite with the full path from git + currentNode.isRenamed = isRenamed + } + } + } + + root.sort() + root.compact() + return root +} + +// Render traverses the tree and returns a slice of formatted strings for display. +func (n *Node) Render(theme Theme) []string { + return n.renderRecursive("", theme) +} + +// findChild searches for an immediate child node by name. +func (n *Node) findChild(name string) *Node { + for _, child := range n.children { + if child.name == name { + return child + } + } + return nil +} + +// sort recursively sorts the children of a node, placing directories before files. +func (n *Node) sort() { + if n.children == nil { + return + } + sort.SliceStable(n.children, func(i, j int) bool { + isDirI := len(n.children[i].children) > 0 + isDirJ := len(n.children[j].children) > 0 + if isDirI != isDirJ { + return isDirI + } + return n.children[i].name < n.children[j].name + }) + + for _, child := range n.children { + child.sort() + } +} + +// compact recursively merges directories that contain only a single sub-directory +// to create a more concise file tree. +func (n *Node) compact() { + if n.children == nil { + return + } + + // Recursively compact children first. + for _, child := range n.children { + child.compact() + } + + // Do not compact the root node itself. + if n.name == repoRootNodeName { + return + } + + // If a directory has only one child and that child is also a directory, merge them. + for len(n.children) == 1 && len(n.children[0].children) > 0 { + child := n.children[0] + n.name = filepath.Join(n.name, child.name) + n.path = child.path + n.children = child.children + } +} + +// renderRecursive performs a depth-first traversal of the tree to generate +// raw, tab-delimited strings for the view to parse and style. +func (n *Node) renderRecursive(prefix string, theme Theme) []string { + var lines []string + for _, child := range n.children { + newPrefix := prefix + theme.Tree.Prefix + + if len(child.children) > 0 { // It's a directory + displayName := dirExpandedIcon + child.name + lines = append(lines, fmt.Sprintf("%s\t\t%s\t%s", prefix, displayName, child.path)) + lines = append(lines, child.renderRecursive(newPrefix, theme)...) + } else { // It's a file. + displayName := child.name + if child.isRenamed { + displayName = child.path + } + lines = append(lines, fmt.Sprintf("%s\t%s\t%s\t%s", prefix, child.status, displayName, child.path)) + } + } + return lines +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..80eda90 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,274 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the keybindings for the application. +type KeyMap struct { + // miscellaneous keybindings + Quit key.Binding + Escape key.Binding + ToggleHelp key.Binding + + // keybindings for changing theme + SwitchTheme key.Binding + + // keybindings for navigation + FocusNext key.Binding + FocusPrev key.Binding + FocusZero key.Binding + FocusOne key.Binding + FocusTwo key.Binding + FocusThree key.Binding + FocusFour key.Binding + FocusFive key.Binding + FocusSix key.Binding + Up key.Binding + Down key.Binding + + // Keybindings for FilesPanel + StageItem key.Binding + StageAll key.Binding + Discard key.Binding + Reset key.Binding + Stash key.Binding + StashAll key.Binding + Commit key.Binding + + // Keybindings for BranchesPanel + Checkout key.Binding + NewBranch key.Binding + DeleteBranch key.Binding + RenameBranch key.Binding + + // Keybindings for CommitsPanel + AmendCommit key.Binding + Revert key.Binding + ResetToCommit key.Binding + + // Keybindings for StashPanel + StashApply key.Binding + StashPop key.Binding + StashDrop key.Binding +} + +// HelpSection is a struct to hold a title and keybindings for a help section. +type HelpSection struct { + Title string + Bindings []key.Binding +} + +// FullHelp returns a structured slice of HelpSection, which is used to build +// the full help view. +func (k KeyMap) FullHelp() []HelpSection { + return []HelpSection{ + { + Title: "Navigation", + Bindings: []key.Binding{ + k.FocusNext, k.FocusPrev, k.FocusZero, k.FocusOne, + k.FocusTwo, k.FocusThree, k.FocusFour, k.FocusFive, + k.FocusSix, k.Up, k.Down, + }, + }, + { + Title: "Files", + Bindings: []key.Binding{ + k.Commit, k.Stash, k.StashAll, k.StageItem, + k.StageAll, k.Discard, k.Reset, + }, + }, + { + Title: "Branches", + Bindings: []key.Binding{k.Checkout, k.NewBranch, k.DeleteBranch, k.RenameBranch}, + }, + { + Title: "Commits", + Bindings: []key.Binding{k.AmendCommit, k.Revert, k.ResetToCommit}, + }, + { + Title: "Stash", + Bindings: []key.Binding{k.StashApply, k.StashPop, k.StashDrop}, + }, + { + Title: "Misc", + Bindings: []key.Binding{k.SwitchTheme, k.ToggleHelp, k.Escape, k.Quit}, + }, + } +} + +// ShortHelp returns a slice of key.Binding containing help for default keybindings. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.ToggleHelp, k.Escape, k.Quit} +} + +// HelpViewHelp returns a slice of key.Binding containing help for keybindings related to Help View. +func (k KeyMap) HelpViewHelp() []key.Binding { + return []key.Binding{k.ToggleHelp, k.Escape, k.Quit} +} + +// FilesPanelHelp returns a slice of key.Binding containing help for keybindings related to Files Panel. +func (k KeyMap) FilesPanelHelp() []key.Binding { + help := []key.Binding{k.Commit, k.Stash, k.Discard, k.StageItem} + return append(help, k.ShortHelp()...) +} + +// BranchesPanelHelp returns a slice of key.Binding for the Branches Panel help bar. +func (k KeyMap) BranchesPanelHelp() []key.Binding { + help := []key.Binding{k.Checkout, k.NewBranch, k.DeleteBranch} + return append(help, k.ShortHelp()...) +} + +// CommitsPanelHelp returns a slice of key.Binding for the Commits Panel help bar. +func (k KeyMap) CommitsPanelHelp() []key.Binding { + help := []key.Binding{k.AmendCommit, k.Revert, k.ResetToCommit} + return append(help, k.ShortHelp()...) +} + +// StashPanelHelp returns a slice of key.Binding for the Stash Panel help bar. +func (k KeyMap) StashPanelHelp() []key.Binding { + help := []key.Binding{k.StashApply, k.StashPop, k.StashDrop} + return append(help, k.ShortHelp()...) +} + +// DefaultKeyMap returns a set of default keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + // misc + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("", "cancel"), + ), + ToggleHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + + // theme + SwitchTheme: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("", "switch theme"), + ), + + // navigation + FocusNext: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "Focus Next Window"), + ), + FocusPrev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("", "Focus Previous Window"), + ), + FocusZero: key.NewBinding( + key.WithKeys("0"), + key.WithHelp("0", "Focus Main Window"), + ), + FocusOne: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "Focus Status Window"), + ), + FocusTwo: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "Focus Files Window"), + ), + FocusThree: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "Focus Branches Window"), + ), + FocusFour: key.NewBinding( + key.WithKeys("4"), + key.WithHelp("4", "Focus Commits Window"), + ), + FocusFive: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "Focus Stash Window"), + ), + FocusSix: key.NewBinding( + key.WithKeys("6"), + key.WithHelp("6", "Focus Command log Window"), + ), + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("k/↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("j/↓", "down"), + ), + + // FilesPanel + StageItem: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "Stage Item"), + ), + StageAll: key.NewBinding( + key.WithKeys("space"), + key.WithHelp("", "Stage All"), + ), + Discard: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "Discard"), + ), + Reset: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "Reset"), + ), + Stash: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "Stash"), + ), + StashAll: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "Stage all"), + ), + Commit: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "Commit"), + ), + + Checkout: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Checkout"), + ), + NewBranch: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "New Branch"), + ), + DeleteBranch: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "Delete"), + ), + RenameBranch: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "Rename"), + ), + + AmendCommit: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "Amend"), + ), + Revert: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "Revert"), + ), + ResetToCommit: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "Reset to Commit"), + ), + + StashApply: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "Apply"), + ), + StashPop: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "Pop"), + ), + StashDrop: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "Drop"), + ), + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..b65ad0e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,122 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/gitxtui/gitx/internal/git" +) + +// appMode defines the different operational modes of the TUI. +type appMode int + +const ( + modeNormal appMode = iota + modeInput + modeConfirm +) + +// Model represents the state of the TUI. +type Model struct { + width int + height int + panels []panel + panelHeights []int + focusedPanel Panel + activeSourcePanel Panel + theme Theme + themeNames []string + themeIndex int + help help.Model + helpViewport viewport.Model + helpContent string + showHelp bool + git *git.GitCommands + repoName string + branchName string + // New fields for pop-ups + mode appMode + promptTitle string + confirmMessage string + textInput textinput.Model + inputCallback func(string) tea.Cmd + confirmCallback func(bool) tea.Cmd +} + +// initialModel creates the initial state of the application. +func initialModel() Model { + themeNames := ThemeNames() + gc := git.NewGitCommands() + repoName, branchName, _ := gc.GetRepoInfo() + initialContent := initialContentLoading + + panels := make([]panel, totalPanels) + for i := range panels { + vp := viewport.New(0, 0) + vp.SetContent(initialContent) + panels[i] = panel{ + viewport: vp, + content: initialContent, + } + } + + ti := textinput.New() + ti.Focus() + ti.CharLimit = 256 + ti.Width = 50 + + return Model{ + theme: Themes[themeNames[0]], + themeNames: themeNames, + themeIndex: 0, + focusedPanel: StatusPanel, + activeSourcePanel: StatusPanel, + help: help.New(), + helpViewport: viewport.New(0, 0), + showHelp: false, + git: gc, + repoName: repoName, + branchName: branchName, + panels: panels, + mode: modeNormal, + textInput: ti, + } +} + +// Init is the first command that is run when the program starts. +func (m Model) Init() tea.Cmd { + // fetch initial content for all panels. + return tea.Batch( + m.fetchPanelContent(StatusPanel), + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(StashPanel), + m.fetchPanelContent(SecondaryPanel), + m.updateMainPanel(), + ) +} + +// nextTheme cycles to the next theme. +func (m *Model) nextTheme() { + m.themeIndex = (m.themeIndex + 1) % len(m.themeNames) + m.theme = Themes[m.themeNames[m.themeIndex]] +} + +// panelShortHelp returns a slice of key.Binding for the focused Panel. +func (m *Model) panelShortHelp() []key.Binding { + switch m.focusedPanel { + case FilesPanel: + return keys.FilesPanelHelp() + case BranchesPanel: + return keys.BranchesPanelHelp() + case CommitsPanel: + return keys.CommitsPanelHelp() + case StashPanel: + return keys.StashPanelHelp() + default: + return keys.ShortHelp() + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..b6cd63c --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,410 @@ +package tui + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + zone "github.com/lrstanley/bubblezone" +) + +type testModel struct { + Model +} + +func TestModel_InitialPanels(t *testing.T) { + m := initialModel() + + if len(m.panels) != int(totalPanels) { + t.Fatalf("expected %d panels, but got %d", totalPanels, len(m.panels)) + } + + for i, p := range m.panels { + if p.content != "Loading..." { + t.Errorf("panel %s content field was not initialized correctly", Panel(i).ID()) + } + } +} + +func TestModel_DynamicLayout(t *testing.T) { + tm := newTestModel() + expandedHeight := int(float64(tm.height-1) * 0.4) + + testCases := []struct { + name string + focusOn Panel + panelToCheck Panel + expectedHeight int + shouldBeExpanded bool + }{ + {"SecondaryPanel is collapsed by default", MainPanel, SecondaryPanel, 3, false}, + {"SecondaryPanel expands on focus", SecondaryPanel, SecondaryPanel, expandedHeight, true}, + {"StashPanel is collapsed by default", MainPanel, StashPanel, 3, false}, + {"StashPanel expands on focus", StashPanel, StashPanel, expandedHeight, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tm.focusedPanel = tc.focusOn + tm.Model = tm.recalculateLayout() + actualHeight := tm.panelHeights[tc.panelToCheck] + + if actualHeight != tc.expectedHeight { + t.Errorf("panel height is incorrect: got %d, want %d", actualHeight, tc.expectedHeight) + } + }) + } +} + +func TestModel_ScrollToTopOnFocus(t *testing.T) { + tm := newTestModel() + tm.panels[StashPanel].viewport.SetContent(strings.Repeat("line\n", 30)) + tm.panels[StashPanel].viewport.YOffset = 10 // Manually scroll down + + // Simulate changing focus to the StashPanel + tm.focusedPanel = MainPanel + updatedModel, _ := tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("5")}) + tm.Model = updatedModel.(Model) + + if tm.focusedPanel != StashPanel { + t.Fatal("Focus did not change to StashPanel as expected") + } + if tm.panels[StashPanel].viewport.YOffset != 0 { + t.Errorf("StashPanel did not scroll to top on focus: YOffset is %d, want 0", tm.panels[StashPanel].viewport.YOffset) + } +} + +func TestModel_ConditionalScrollbar(t *testing.T) { + zone.NewGlobal() + defer zone.Close() + + tm := newTestModel() + longContent := strings.Repeat("line\n", 30) + longContentLines := strings.Split(longContent, "\n") + + // Set up content for StashPanel + tm.panels[StashPanel].content = longContent + tm.panels[StashPanel].lines = longContentLines + tm.panels[StashPanel].viewport.SetContent(longContent) + + // Set up content for CommitsPanel + tm.panels[CommitsPanel].content = longContent + tm.panels[CommitsPanel].lines = longContentLines + tm.panels[CommitsPanel].viewport.SetContent(longContent) + + t.Run("Scrollbar is hidden when StashPanel is not focused", func(t *testing.T) { + tm.focusedPanel = MainPanel + rendered := tm.renderPanel("Stash", 30, tm.panelHeights[StashPanel], StashPanel) + if strings.Contains(rendered, scrollThumbChar) { + t.Error("Scrollbar thumb should be hidden but was found") + } + }) + + t.Run("Scrollbar is visible when StashPanel is focused", func(t *testing.T) { + tm.focusedPanel = StashPanel + rendered := tm.renderPanel("Stash", 30, tm.panelHeights[StashPanel], StashPanel) + if !strings.Contains(rendered, scrollThumbChar) { + t.Error("Scrollbar thumb should be visible but was not found") + } + }) + + t.Run("Normal panel scrollbar is always visible if scrollable", func(t *testing.T) { + tm.focusedPanel = MainPanel // Focus is NOT on CommitsPanel + rendered := tm.renderPanel("Commits", 30, tm.panelHeights[CommitsPanel], CommitsPanel) + if !strings.Contains(rendered, scrollThumbChar) { + t.Error("Scrollbar thumb should be visible but was not found") + } + }) +} + +func TestModelPanelCycle(t *testing.T) { + tm := newTestModel() + t.Run("shift focus to next panel", func(t *testing.T) { + tm.focusedPanel = MainPanel + tm.nextPanel() + assertPanel(t, tm.focusedPanel, StatusPanel) + }) + t.Run("shift focus to previous panel", func(t *testing.T) { + tm.focusedPanel = StatusPanel + tm.prevPanel() + assertPanel(t, tm.focusedPanel, MainPanel) + }) +} + +func TestModel_KeyFocus(t *testing.T) { + testCases := []struct { + name string + initialPanel Panel + key string + expectedPanel Panel + }{ + {"Focus Next with Tab", StatusPanel, "tab", FilesPanel}, + {"Focus Previous with Shift+Tab", FilesPanel, "shift+tab", StatusPanel}, + {"Direct Focus with '0'", StashPanel, "0", MainPanel}, + {"Direct Focus with '1'", MainPanel, "1", StatusPanel}, + {"Direct Focus with '2'", MainPanel, "2", FilesPanel}, + {"Direct Focus with '3'", MainPanel, "3", BranchesPanel}, + {"Direct Focus with '4'", MainPanel, "4", CommitsPanel}, + {"Direct Focus with '5'", MainPanel, "5", StashPanel}, + {"Direct Focus with '6'", MainPanel, "6", SecondaryPanel}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := initialModel() + m.focusedPanel = tc.initialPanel + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)} + if tc.key == "tab" { + keyMsg.Type = tea.KeyTab + } + if tc.key == "shift+tab" { + keyMsg.Type = tea.KeyShiftTab + } + + updatedModel, _ := m.Update(keyMsg) + assertPanel(t, updatedModel.(Model).focusedPanel, tc.expectedPanel) + }) + } +} + +func TestModel_contextualHelp(t *testing.T) { + m := initialModel() + keys = DefaultKeyMap() + t.Run("Files Panel Help", func(t *testing.T) { + m.focusedPanel = FilesPanel + gotKeys := m.panelShortHelp() + assertKeyBindingsEqual(t, gotKeys, keys.FilesPanelHelp()) + }) +} + +func TestModel_HelpToggle(t *testing.T) { + m := initialModel() + t.Run("toggles help on", func(t *testing.T) { + updatedModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) + if !updatedModel.(Model).showHelp { + t.Error("showHelp should be true after pressing '?'") + } + }) + t.Run("toggles help off", func(t *testing.T) { + m.showHelp = true + updatedModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) + if updatedModel.(Model).showHelp { + t.Error("showHelp should be false after pressing '?'") + } + }) +} + +func TestModel_MouseFocus(t *testing.T) { + zone.NewGlobal() + defer zone.Close() + + testCases := []struct { + name string + targetPanel Panel + }{ + {"clicking on FilesPanel changes focus", FilesPanel}, + {"clicking on SecondaryPanel changes focus", SecondaryPanel}, + } + + for _, tc := range testCases { + t.Skip("WILL FIX") + t.Run(tc.name, func(t *testing.T) { + tm := newTestModel() + tm.focusedPanel = MainPanel + + zone.Scan(tm.View()) + time.Sleep(20 * time.Millisecond) + + panelZone := zone.Get(tc.targetPanel.ID()) + if panelZone.IsZero() { + t.Fatalf("Could not find zone for %s. Is zone.Mark() used in the View?", tc.targetPanel.ID()) + } + + msg := tea.MouseMsg{ + X: panelZone.StartX, + Y: panelZone.StartY, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionRelease, + } + updatedModel, _ := tm.Update(msg) + + assertPanel(t, updatedModel.(Model).focusedPanel, tc.targetPanel) + }) + } +} + +func TestModel_ScrollInactivePanelWithMouse(t *testing.T) { + t.Skip("WILL FIX") + zone.NewGlobal() + zone.SetEnabled(true) + defer zone.Close() + + tm := newTestModel() + scrollablePanel := CommitsPanel + focusedPanel := MainPanel + + // 1. Setup: Make a panel scrollable and focus another panel. + longContent := strings.Repeat("line\n", 30) + tm.panels[scrollablePanel].content = longContent + tm.panels[scrollablePanel].lines = strings.Split(longContent, "\n") + tm.panels[scrollablePanel].viewport.SetContent(longContent) + tm.focusedPanel = focusedPanel + + // 2. Render the view and scan it to register the zones with the zone manager. + view := tm.View() + zone.Scan(view) + + // 3. Get the zone for the inactive panel we want to scroll. + panelZone := zone.Get(scrollablePanel.ID()) + if panelZone.IsZero() { + t.Fatalf("Could not find zone for %s. Is zone.Mark() used in the View?", scrollablePanel.ID()) + } + + // 4. Create a modern mouse scroll event that happens inside the inactive panel's zone. + scrollMsg := tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + X: panelZone.StartX, // Within the zone's X bounds + Y: panelZone.StartY, // Within the zone's Y bounds + } + + // 5. Act: Send the scroll message to the model. + updatedModel, _ := tm.Update(scrollMsg) + tm.Model = updatedModel.(Model) + + // 6. Assert: The inactive panel's viewport should have scrolled. + if tm.panels[scrollablePanel].viewport.YOffset == 0 { + t.Errorf("expected inactive panel %s to scroll, but YOffset remained 0", scrollablePanel.ID()) + } + if tm.focusedPanel != focusedPanel { + t.Errorf("focus should not have changed. want %s, got %s", focusedPanel.ID(), tm.focusedPanel.ID()) + } +} + +func TestModel_LineSelectionAndScrolling(t *testing.T) { + selectablePanels := []Panel{FilesPanel, BranchesPanel, CommitsPanel, StashPanel} + + for _, panel := range selectablePanels { + t.Run(fmt.Sprintf("for %s", panel.ID()), func(t *testing.T) { + tm := newTestModel() + // Make viewport small to test scrolling behavior + tm.panels[panel].viewport.Height = 5 + + longContent := strings.Repeat("line\n", 10) + lines := strings.Split(strings.TrimSpace(longContent), "\n") + tm.panels[panel].lines = lines + tm.panels[panel].content = longContent + tm.panels[panel].viewport.SetContent(longContent) + tm.focusedPanel = panel + + // Test mouse click selection + t.Run("mouse click moves cursor", func(t *testing.T) { + clickMsg := lineClickedMsg{panel: panel, lineIndex: 3} + updatedModel, _ := tm.Update(clickMsg) + tm.Model = updatedModel.(Model) + + if tm.panels[panel].cursor != 3 { + t.Errorf("cursor should be at index 3 after click, got %d", tm.panels[panel].cursor) + } + }) + + // Test keyboard down and up + t.Run("arrow keys move cursor", func(t *testing.T) { + tm.panels[panel].cursor = 1 // reset + downKey := tea.KeyMsg{Type: tea.KeyDown} + updatedModel, _ := tm.Update(downKey) + tm.Model = updatedModel.(Model) + + if tm.panels[panel].cursor != 2 { + t.Errorf("cursor should be at index 2 after pressing down, got %d", tm.panels[panel].cursor) + } + + upKey := tea.KeyMsg{Type: tea.KeyUp} + updatedModel, _ = tm.Update(upKey) + tm.Model = updatedModel.(Model) + + if tm.panels[panel].cursor != 1 { + t.Errorf("cursor should be at index 1 after pressing up, got %d", tm.panels[panel].cursor) + } + }) + + // Test viewport scrolling down + t.Run("viewport scrolls down with cursor", func(t *testing.T) { + tm.panels[panel].cursor = 4 // Last visible line (0,1,2,3,4) + tm.panels[panel].viewport.YOffset = 0 + + downKey := tea.KeyMsg{Type: tea.KeyDown} + updatedModel, _ := tm.Update(downKey) + tm.Model = updatedModel.(Model) + + if tm.panels[panel].cursor != 5 { + t.Fatalf("cursor should be at index 5, got %d", tm.panels[panel].cursor) + } + // cursor is 5, height is 5. YOffset should be 5 - 5 + 1 = 1 + if tm.panels[panel].viewport.YOffset != 1 { + t.Errorf("viewport should scroll down. YOffset should be 1, got %d", tm.panels[panel].viewport.YOffset) + } + }) + + // Test viewport scrolling up + t.Run("viewport scrolls up with cursor", func(t *testing.T) { + tm.panels[panel].cursor = 1 + tm.panels[panel].viewport.YOffset = 1 + + upKey := tea.KeyMsg{Type: tea.KeyUp} + updatedModel, _ := tm.Update(upKey) + tm.Model = updatedModel.(Model) + + if tm.panels[panel].cursor != 0 { + t.Fatalf("cursor should be at index 0, got %d", tm.panels[panel].cursor) + } + if tm.panels[panel].viewport.YOffset != 0 { + t.Errorf("viewport should scroll up. YOffset should be 0, got %d", tm.panels[panel].viewport.YOffset) + } + }) + }) + } +} + +func TestModel_Update_FileWatcher(t *testing.T) { + m := initialModel() + _, cmd := m.Update(fileWatcherMsg{}) + + if cmd == nil { + t.Fatal("expected a command to be returned") + } + + cmds := cmd().(tea.BatchMsg) + expectedCmds := 5 + if len(cmds) != expectedCmds { + t.Errorf("expected %d commands, got %d", expectedCmds, len(cmds)) + } +} + +// newTestModel creates a new model with default dimensions and a calculated layout. +func newTestModel() testModel { + m := initialModel() + m.width = 100 + m.height = 31 + m = m.recalculateLayout() + return testModel{m} +} + +// assertPanel is a helper to compare focused panels. +func assertPanel(t *testing.T, got, want Panel) { + t.Helper() + if got != want { + t.Errorf("got focused panel %v, want %v", got, want) + } +} + +// assertKeyBindingsEqual is a helper to compare two slices of key.Binding. +func assertKeyBindingsEqual(t *testing.T, got, want []key.Binding) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Errorf("\n\tgot \t%v\n\twant \t%v", got, want) + } +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go new file mode 100644 index 0000000..54a65a3 --- /dev/null +++ b/internal/tui/panels.go @@ -0,0 +1,51 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/viewport" +) + +// Panel is an enumeration of all the panels in the UI. +type Panel int + +// Defines the available panels in the UI. +const ( + MainPanel Panel = iota + StatusPanel + FilesPanel + BranchesPanel + CommitsPanel + StashPanel + SecondaryPanel + totalPanels // Used to iterate over all panels +) + +// ID returns a string ID for the panel, used for mouse zone detection. +func (p Panel) ID() string { + return fmt.Sprintf("panel-%d", p) +} + +// panel represents the state of a single UI panel. +type panel struct { + viewport viewport.Model + content string + lines []string + cursor int +} + +// nextPanel shifts focus to the next Panel. +func (m *Model) nextPanel() { + // Skips SecondaryPanel + m.focusedPanel = (m.focusedPanel + 1) % (totalPanels - 1) +} + +// prevPanel shifts focus to the previous Panel. +func (m *Model) prevPanel() { + // skip SecondaryPanel + if m.focusedPanel == 0 { + m.focusedPanel = totalPanels - 2 + return + } + m.focusedPanel = m.focusedPanel - 1 +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..ecce002 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,218 @@ +package tui + +import ( + "sort" + + "github.com/charmbracelet/lipgloss" +) + +// Palette defines a set of colors for a theme. +type Palette struct { + Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, + BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite, + DarkBlack, DarkRed, DarkGreen, DarkYellow, DarkBlue, DarkMagenta, DarkCyan, DarkWhite, + Bg, Fg string +} + +// Palettes holds all the available color palettes. +var Palettes = map[string]Palette{ + "GitHub Dark": { + // Normal + Black: "#24292E", + Red: "#ff7b72", + Green: "#3fb950", + Yellow: "#d29922", + Blue: "#58a6ff", + Magenta: "#bc8cff", + Cyan: "#39c5cf", + White: "#b1bac4", + + // Bright + BrightBlack: "#6e7681", + BrightRed: "#ffa198", + BrightGreen: "#56d364", + BrightYellow: "#e3b341", + BrightBlue: "#79c0ff", + BrightMagenta: "#d2a8ff", + BrightCyan: "#56d4dd", + BrightWhite: "#f0f6fc", + + // Dark + DarkBlack: "#1b1f23", + DarkRed: "#d73a49", + DarkGreen: "#28a745", + DarkYellow: "#dbab09", + DarkBlue: "#2188ff", + DarkMagenta: "#a041f5", + DarkCyan: "#12aab5", + DarkWhite: "#8b949e", + + // Special + Bg: "#0d1117", + Fg: "#c9d1d9", + }, + "Gruvbox": { + // Normal + Black: "#282828", + Red: "#cc241d", + Green: "#98971a", + Yellow: "#d79921", + Blue: "#458588", + Magenta: "#b16286", + Cyan: "#689d6a", + White: "#a89984", + + // Bright + BrightBlack: "#928374", + BrightRed: "#fb4934", + BrightGreen: "#b8bb26", + BrightYellow: "#fabd2f", + BrightBlue: "#83a598", + BrightMagenta: "#d3869b", + BrightCyan: "#8ec07c", + BrightWhite: "#ebdbb2", + + // Dark + DarkBlack: "#1d2021", + DarkRed: "#9d0006", + DarkGreen: "#79740e", + DarkYellow: "#b57614", + DarkBlue: "#076678", + DarkMagenta: "#8f3f71", + DarkCyan: "#427b58", + DarkWhite: "#928374", + + // Special + Bg: "#282828", + Fg: "#ebdbb2", + }, +} + +// Theme represents the styles for different components of the UI. +type Theme struct { + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style + NormalText lipgloss.Style + HelpTitle lipgloss.Style + HelpKey lipgloss.Style + HelpButton lipgloss.Style + ScrollbarThumb lipgloss.Style + SelectedLine lipgloss.Style + Hyperlink lipgloss.Style + WelcomeHeading lipgloss.Style + WelcomeMsg lipgloss.Style + UserName lipgloss.Style + GitStaged lipgloss.Style + GitUnstaged lipgloss.Style + GitUntracked lipgloss.Style + GitConflicted lipgloss.Style + BranchCurrent lipgloss.Style + BranchDate lipgloss.Style + CommitSHA lipgloss.Style + CommitAuthor lipgloss.Style + CommitMerge lipgloss.Style + GraphEdge lipgloss.Style + GraphNode lipgloss.Style + GraphColors []lipgloss.Style + StashName lipgloss.Style + StashMessage lipgloss.Style + ActiveBorder BorderStyle + InactiveBorder BorderStyle + Tree TreeStyle +} + +// BorderStyle defines the characters and styles for a panel's border. +type BorderStyle struct { + Top string + Bottom string + Left string + Right string + TopLeft string + TopRight string + BottomLeft string + BottomRight string + Style lipgloss.Style +} + +// TreeStyle defines the characters used to render the file tree. +type TreeStyle struct { + Connector, ConnectorLast, Prefix, PrefixLast string +} + +// Themes holds all the available themes, generated from palettes. +var Themes = map[string]Theme{} + +func init() { + for name, p := range Palettes { + Themes[name] = NewThemeFromPalette(p) + } +} + +// NewThemeFromPalette creates a Theme from a given color Palette. +func NewThemeFromPalette(p Palette) Theme { + return Theme{ + ActiveTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Bg)).Background(lipgloss.Color(p.BrightCyan)), + InactiveTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Fg)).Background(lipgloss.Color(p.Black)), + NormalText: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Fg)), + HelpTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)).Bold(true), + HelpKey: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Yellow)), + HelpButton: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Bg)).Background(lipgloss.Color(p.Green)).Margin(0, 1), + ScrollbarThumb: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightGreen)), + SelectedLine: lipgloss.NewStyle().Background(lipgloss.Color(p.DarkBlue)).Foreground(lipgloss.Color(p.BrightWhite)), + Hyperlink: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightBlue)).Underline(true), + WelcomeHeading: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightCyan)), + WelcomeMsg: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightGreen)), + UserName: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Magenta)), + GitStaged: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)), + GitUnstaged: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Red)), + GitUntracked: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightBlack)), + GitConflicted: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightRed)).Bold(true), + BranchCurrent: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)).Bold(true), + BranchDate: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Yellow)), + CommitSHA: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Yellow)), + CommitAuthor: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)), + CommitMerge: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Magenta)), + GraphEdge: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightBlack)), + GraphNode: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)), + GraphColors: []lipgloss.Style{ + lipgloss.NewStyle().Foreground(lipgloss.Color(p.Green)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.Yellow)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.Blue)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.Magenta)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.Cyan)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightGreen)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightYellow)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightBlue)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightMagenta)), + lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightCyan)), + }, + StashName: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Yellow)), + StashMessage: lipgloss.NewStyle().Foreground(lipgloss.Color(p.Fg)), + ActiveBorder: BorderStyle{ + Top: borderTop, Bottom: borderBottom, Left: borderLeft, Right: borderRight, + TopLeft: borderTopLeft, TopRight: borderTopRight, BottomLeft: borderBottomLeft, BottomRight: borderBottomRight, + Style: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightCyan)), + }, + InactiveBorder: BorderStyle{ + Top: borderTop, Bottom: borderBottom, Left: borderLeft, Right: borderRight, + TopLeft: borderTopLeft, TopRight: borderTopRight, BottomLeft: borderBottomLeft, BottomRight: borderBottomRight, + Style: lipgloss.NewStyle().Foreground(lipgloss.Color(p.BrightBlack)), + }, + Tree: TreeStyle{ + Connector: treeConnector, + ConnectorLast: treeConnectorLast, + Prefix: treePrefix, + PrefixLast: treePrefixLast, + }, + } +} + +// ThemeNames returns a slice of the available theme names. +func ThemeNames() []string { + names := make([]string, 0, len(Palettes)) + for name := range Palettes { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ca47984..58657a6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,75 +1,99 @@ package tui import ( - "fmt" + "log" + "path/filepath" + "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/fsnotify/fsnotify" "github.com/gitxtui/gitx/internal/git" ) -type Model struct { - content string - err error -} - +// App is the main application struct. type App struct { program *tea.Program } +// NewApp initializes a new TUI application. func NewApp() *App { - status, err := git.GetStatus() - if err != nil { - status = err.Error() - } - - model := Model{ - content: status, - err: err, + m := initialModel() + return &App{ + program: tea.NewProgram( + m, + tea.WithoutCatchPanics(), + tea.WithAltScreen(), + tea.WithMouseAllMotion(), + ), } - - program := tea.NewProgram(model, tea.WithAltScreen()) - - return &App{program: program} } +// Run starts the TUI application and the file watcher. func (a *App) Run() error { + go a.watchGitDir() + // program.Run() returns the final model and an error. We only need the error. _, err := a.program.Run() return err } -func (m Model) Init() tea.Cmd { - return nil -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit +// watchGitDir starts a file watcher on the .git directory and sends a message on change. +func (a *App) watchGitDir() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("error creating file watcher: %v", err) + return + } + defer func() { + if err := watcher.Close(); err != nil { + log.Printf("error closing file watcher: %v", err) } + }() + + gc := git.NewGitCommands() + gitDir, err := gc.GetGitRepoPath() + if err != nil { + // Not in a git repo, no need to watch. + return } - return m, nil -} -func (m Model) View() string { - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")). - Padding(1, 2). - Width(80). - Height(24) + repoRoot := filepath.Dir(gitDir) + + watchPaths := []string{ + repoRoot, + gitDir, + filepath.Join(gitDir, "HEAD"), + filepath.Join(gitDir, "index"), + filepath.Join(gitDir, "refs"), + } - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("15")). - Background(lipgloss.Color("240")). - Padding(0, 1). - MarginBottom(1) + for _, path := range watchPaths { + if err := watcher.Add(path); err != nil { + // ignore errors for paths that might not exist yet + log.Printf("error watching path %s: %v", path, err.Error()) + } + } - title := titleStyle.Render("Git Status") - content := borderStyle.Render(m.content) + ticker := time.NewTicker(fileWatcherPollInterval) + defer ticker.Stop() + var needsUpdate bool - return fmt.Sprintf("%s\n%s\n\nPress 'q' or 'ctrl+c' to quit", title, content) + for { + select { + case _, ok := <-watcher.Events: + if !ok { + return + } + needsUpdate = true // Set flag on any event. + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("file watcher error: %v", err) + case <-ticker.C: + if needsUpdate { + a.program.Send(fileWatcherMsg{}) + needsUpdate = false + } + } + } } diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..7cc5e84 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,832 @@ +package tui + +import ( + "fmt" + "log" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/gitxtui/gitx/internal/git" + zone "github.com/lrstanley/bubblezone" +) + +var keys = DefaultKeyMap() + +// panelContentUpdatedMsg is sent when new content for a panel has been fetched. +type panelContentUpdatedMsg struct { + panel Panel + content string +} + +// mainContentUpdatedMsg is sent when the content for the main panel has been fetched. +type mainContentUpdatedMsg struct { + content string +} + +// lineClickedMsg is sent when a user clicks on a line in a selectable panel. +type lineClickedMsg struct { + panel Panel + lineIndex int +} + +// fileWatcherMsg is sent by the file watcher when the repository state changes. +type fileWatcherMsg struct{} + +// errMsg is used to propagate errors back to the update loop. +type errMsg struct{ err error } + +func (e errMsg) Error() string { return e.err.Error() } + +// Update is the main message handler for the TUI. It processes user input, +// window events, and application-specific messages. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.mode { + case modeInput: + return m.updateInput(msg) + case modeConfirm: + return m.updateConfirm(msg) + } + + var cmd tea.Cmd + var cmds []tea.Cmd + oldFocus := m.focusedPanel + + switch msg := msg.(type) { + case errMsg: + // You can improve this to show errors in the UI + log.Printf("error: %v", msg) + return m, nil + + case mainContentUpdatedMsg: + m.panels[MainPanel].content = msg.content + m.panels[MainPanel].viewport.SetContent(msg.content) + return m, nil + + case panelContentUpdatedMsg: + var selectedPath string + // If the FilesPanel is being updated, try to find the path of the + // currently selected item to preserve the cursor position after the refresh. + if msg.panel == FilesPanel && m.panels[FilesPanel].cursor < len(m.panels[FilesPanel].lines) { + line := m.panels[FilesPanel].lines[m.panels[FilesPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) == 4 { + selectedPath = parts[3] + } + } + + oldCursor := m.panels[msg.panel].cursor + + if msg.panel == FilesPanel { + root := BuildTree(msg.content) + renderedTree := root.Render(m.theme) + m.panels[FilesPanel].lines = renderedTree + m.panels[FilesPanel].viewport.SetContent(strings.Join(renderedTree, "\n")) + + // Restore the cursor to the previously selected file path. + newCursorPos := 0 // Default to top. + if selectedPath != "" { + for i, line := range renderedTree { + parts := strings.Split(line, "\t") + if len(parts) == 4 && parts[3] == selectedPath { + newCursorPos = i + break + } + } + } + m.panels[FilesPanel].cursor = newCursorPos + } else { + lines := strings.Split(msg.content, "\n") + m.panels[msg.panel].lines = lines + m.panels[msg.panel].viewport.SetContent(msg.content) + m.panels[msg.panel].content = msg.content + + // Restore cursor by index for other, more stable panels. + if oldCursor < len(lines) { + m.panels[msg.panel].cursor = oldCursor + } else if len(lines) > 0 { + m.panels[msg.panel].cursor = len(lines) - 1 + } else { + m.panels[msg.panel].cursor = 0 + } + } + return m, m.updateMainPanel() + + case fileWatcherMsg: + // When the repository changes, trigger a content refresh for all panels. + return m, tea.Batch( + m.fetchPanelContent(StatusPanel), + m.fetchPanelContent(FilesPanel), + m.fetchPanelContent(BranchesPanel), + m.fetchPanelContent(CommitsPanel), + m.fetchPanelContent(StashPanel), + ) + + case lineClickedMsg: + // Handle direct selection of a line via mouse click. + if msg.lineIndex < len(m.panels[msg.panel].lines) { + p := &m.panels[msg.panel] + p.cursor = msg.lineIndex + // Ensure the selected line is visible in the viewport. + if p.cursor < p.viewport.YOffset { + p.viewport.SetYOffset(p.cursor) + } + if p.cursor >= p.viewport.YOffset+p.viewport.Height { + p.viewport.SetYOffset(p.cursor - p.viewport.Height + 1) + } + } + m.activeSourcePanel = msg.panel + m.panels[MainPanel].viewport.GotoTop() + return m, m.updateMainPanel() + + case tea.WindowSizeMsg: + m, cmd = m.handleWindowSizeMsg(msg) + cmds = append(cmds, cmd) + + case tea.MouseMsg: + m, cmd = m.handleMouseMsg(msg) + cmds = append(cmds, cmd) + + case tea.KeyMsg: + switch { + case key.Matches(msg, keys.Quit): + return m, tea.Quit + case key.Matches(msg, keys.ToggleHelp): + m.toggleHelp() + return m, nil + case key.Matches(msg, keys.SwitchTheme): + m.nextTheme() + return m, nil + case key.Matches(msg, keys.FocusNext), key.Matches(msg, keys.FocusPrev), + key.Matches(msg, keys.FocusZero), key.Matches(msg, keys.FocusOne), + key.Matches(msg, keys.FocusTwo), key.Matches(msg, keys.FocusThree), + key.Matches(msg, keys.FocusFour), key.Matches(msg, keys.FocusFive), + key.Matches(msg, keys.FocusSix): + m.handleFocusKeys(msg) + } + + cmd = m.handlePanelKeys(msg) + if cmd != nil { + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + } + + if m.focusedPanel != oldFocus { + // When focus changes, reset scroll for the Stash and Secondary panels + if m.focusedPanel == StashPanel || m.focusedPanel == SecondaryPanel { + m.panels[m.focusedPanel].viewport.GotoTop() + } + + // Update the active source panel and main panel content if the new focus is a source panel + if m.focusedPanel != MainPanel && m.focusedPanel != SecondaryPanel { + m.activeSourcePanel = m.focusedPanel + m.panels[MainPanel].viewport.GotoTop() // Reset main panel scroll on source change + cmd = m.updateMainPanel() + cmds = append(cmds, cmd) + } + + m = m.recalculateLayout() + } + + // The original viewport update logic for scrolling + m.panels[m.focusedPanel].viewport, cmd = m.panels[m.focusedPanel].viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// updateInput handles updates when in text input mode. +func (m Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + val := m.textInput.Value() + cmd = m.inputCallback(val) + m.mode = modeNormal + m.textInput.Reset() + return m, cmd + case tea.KeyEsc: + m.mode = modeNormal + m.textInput.Reset() + return m, nil + } + } + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// updateConfirm handles updates when in confirmation mode. +func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch strings.ToLower(msg.String()) { + case "y": + cmd = m.confirmCallback(true) + m.mode = modeNormal + return m, cmd + case "n", "esc": + cmd = m.confirmCallback(false) + m.mode = modeNormal + return m, cmd + } + } + return m, nil +} + +// fetchPanelContent returns a command that fetches the content for a specific panel. +func (m Model) fetchPanelContent(panel Panel) tea.Cmd { + return func() tea.Msg { + var content, repoName, branchName string + var err error + switch panel { + case StatusPanel: + repoName, branchName, err = m.git.GetRepoInfo() + if err == nil { + repo := m.theme.BranchCurrent.Render(repoName) + branch := m.theme.BranchCurrent.Render(branchName) + content = fmt.Sprintf("%s → %s", repo, branch) + } + case FilesPanel: + content, err = m.git.GetStatus(git.StatusOptions{Porcelain: true}) + case BranchesPanel: + var branchList []*git.Branch + branchList, err = m.git.GetBranches() + if err == nil { + var builder strings.Builder + for _, b := range branchList { + name := b.Name + if b.IsCurrent { + name = fmt.Sprintf("(*) → %s", b.Name) + } + line := fmt.Sprintf("%s\t%s", b.LastCommit, name) + builder.WriteString(line + "\n") + } + content = strings.TrimSpace(builder.String()) + } + case CommitsPanel: + var logs []git.CommitLog + logs, err = m.git.GetCommitLogsGraph() + if err == nil { + var builder strings.Builder + for _, log := range logs { + var line string + if log.SHA != "" { + line = fmt.Sprintf("%s\t%s\t%s\t%s", log.Graph, log.SHA, log.AuthorInitials, log.Subject) + } else { + line = log.Graph + } + builder.WriteString(line + "\n") + } + content = strings.TrimSpace(builder.String()) + } + case StashPanel: + var stashList []*git.Stash + stashList, err = m.git.GetStashes() + if err == nil { + if len(stashList) == 0 { + content = "No stashed changes." + } else { + var builder strings.Builder + for _, s := range stashList { + line := fmt.Sprintf("%s\t%s: %s", s.Name, s.Branch, s.Message) + builder.WriteString(line + "\n") + } + content = strings.TrimSpace(builder.String()) + } + } + case SecondaryPanel: + url := m.theme.Hyperlink.Render(githubMainPage) + content = strings.Join([]string{ + "\t--- Feature in development! ---", + "\n\t* This panel will contain all the command logs and history for of TUI app.", + fmt.Sprintf("\t* visit for more details: %s.", url), + }, "\n") + } + + if err != nil { + content = "Error: " + err.Error() + } + return panelContentUpdatedMsg{panel: panel, content: content} + } +} + +// updateMainPanel returns a command that fetches the content for the main panel +// based on the currently active source panel. +func (m *Model) updateMainPanel() tea.Cmd { + return func() tea.Msg { + var content string + var err error + switch m.activeSourcePanel { + case StatusPanel: + userName, _ := m.git.GetUserName() + url := m.theme.Hyperlink.Render(docsPage) + msgHeading := m.theme.WelcomeHeading.Render(asciiArt) + msgBody := fmt.Sprintf(welcomeMsg, m.theme.UserName.Render(userName), url) + content = fmt.Sprintf(msgHeading, m.theme.WelcomeMsg.Render(msgBody)) + case FilesPanel: + if m.panels[FilesPanel].cursor < len(m.panels[FilesPanel].lines) { + line := m.panels[FilesPanel].lines[m.panels[FilesPanel].cursor] + parts := strings.Split(line, "\t") + + if len(parts) == 4 { + status := parts[1] + path := parts[3] // Always use the full path from the 4th column + + if path != "" { + if status == "" { // It's a directory + content, err = m.git.ShowDiff(git.DiffOptions{Color: true, Commit1: "HEAD", Commit2: path}) + } else { // It's a file + stagedChanges := status[0] != ' ' && status[0] != '?' + unstagedChanges := status[1] != ' ' + + if stagedChanges { + content, err = m.git.ShowDiff(git.DiffOptions{Color: true, Cached: true, Commit1: path}) + } else if unstagedChanges { + content, err = m.git.ShowDiff(git.DiffOptions{Color: true, Commit1: path}) + } else if status == "??" { + content = "Untracked file: Stage to see content as a diff." + } + } + } + } + } + case BranchesPanel: + if m.panels[BranchesPanel].cursor < len(m.panels[BranchesPanel].lines) { + line := m.panels[BranchesPanel].lines[m.panels[BranchesPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) > 1 { + branchName := strings.TrimSpace(strings.TrimPrefix(parts[1], "(*) → ")) + content, err = m.git.ShowLog(git.LogOptions{Graph: true, Color: "always", Branch: branchName}) + } + } + case CommitsPanel: + if m.panels[CommitsPanel].cursor < len(m.panels[CommitsPanel].lines) { + line := m.panels[CommitsPanel].lines[m.panels[CommitsPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) >= 2 { + sha := parts[1] + content, err = m.git.ShowCommit(sha) + } + } + case StashPanel: + if len(m.panels[StashPanel].lines) == 1 && m.panels[StashPanel].lines[0] == "No stashed changes." { + content = "No stashed changes." + } else if m.panels[StashPanel].cursor < len(m.panels[StashPanel].lines) { + line := m.panels[StashPanel].lines[m.panels[StashPanel].cursor] + parts := strings.SplitN(line, "\t", 2) + if len(parts) > 0 { + stashID := parts[0] + content, err = m.git.Stash(git.StashOptions{Show: true, StashID: stashID}) + } + } + } + + if err != nil { + content = "Error: " + err.Error() + } + if content == "" { + content = "Select an item to see details." + } + return mainContentUpdatedMsg{content: content} + } +} + +// handleWindowSizeMsg recalculates the layout and resizes all viewports on window resize. +func (m Model) handleWindowSizeMsg(msg tea.WindowSizeMsg) (Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + m.help.Width = msg.Width + m.helpViewport.Width = int(float64(m.width) * helpViewWidthRatio) + m.helpViewport.Height = int(float64(m.height) * helpViewHeightRatio) + + m = m.recalculateLayout() + return m, nil +} + +// handleMouseMsg handles all mouse events, including clicks and scrolling. +func (m Model) handleMouseMsg(msg tea.MouseMsg) (Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + if m.showHelp { + if zone.Get("help-button").InBounds(msg) && msg.Action == tea.MouseActionRelease { + m.toggleHelp() + } else { + m.helpViewport, cmd = m.helpViewport.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + + if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { + if zone.Get("help-button").InBounds(msg) { + m.toggleHelp() + return m, nil + } + + // Check for clicks on selectable lines first. + for p := range m.panels { + panel := Panel(p) + if panel != FilesPanel && panel != BranchesPanel && panel != CommitsPanel && panel != StashPanel { + continue + } + for i := 0; i < len(m.panels[panel].lines); i++ { + lineID := fmt.Sprintf("%s-line-%d", panel.ID(), i) + if zone.Get(lineID).InBounds(msg) { + m.focusedPanel = panel + return m, func() tea.Msg { + return lineClickedMsg{panel: panel, lineIndex: i} + } + } + } + } + + // If no line was clicked, check for clicks on the panel itself to change focus. + for i := range m.panels { + if zone.Get(Panel(i).ID()).InBounds(msg) { + m.focusedPanel = Panel(i) + break + } + } + } + + // Pass mouse events to the corresponding panel's viewport for scrolling. + for i := range m.panels { + panel := Panel(i) + if zone.Get(panel.ID()).InBounds(msg) { + m.panels[panel].viewport, cmd = m.panels[panel].viewport.Update(msg) + cmds = append(cmds, cmd) + break + } + } + + return m, tea.Batch(cmds...) +} + +// handlePanelKeys handles keybindings that are specific to the focused panel. +func (m *Model) handlePanelKeys(msg tea.KeyMsg) tea.Cmd { + switch m.focusedPanel { + case FilesPanel: + return m.handleFilesPanelKeys(msg) + case BranchesPanel: + return m.handleBranchesPanelKeys(msg) + case CommitsPanel: + return m.handleCommitsPanelKeys(msg) + case StashPanel: + return m.handleStashPanelKeys(msg) + } + return nil +} + +// handleCursorMovement is a helper to handle up/down cursor movement in selectable panels. +// It returns true if the key was handled. +func (m *Model) handleCursorMovement(msg tea.KeyMsg) (bool, tea.Cmd) { + p := &m.panels[m.focusedPanel] + itemSelected := false + switch { + case key.Matches(msg, keys.Up): + if p.cursor > 0 { + p.cursor-- + if p.cursor < p.viewport.YOffset { + p.viewport.SetYOffset(p.cursor) + } + itemSelected = true + } + case key.Matches(msg, keys.Down): + if p.cursor < len(p.lines)-1 { + p.cursor++ + if p.cursor >= p.viewport.YOffset+p.viewport.Height { + p.viewport.SetYOffset(p.cursor - p.viewport.Height + 1) + } + itemSelected = true + } + } + if itemSelected { + m.panels[MainPanel].viewport.GotoTop() + return true, m.updateMainPanel() + } + return false, nil +} + +func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { + if handled, cmd := m.handleCursorMovement(msg); handled { + return cmd + } + + if m.panels[FilesPanel].cursor >= len(m.panels[FilesPanel].lines) { + return nil + } + line := m.panels[FilesPanel].lines[m.panels[FilesPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) < 4 { + return nil + } + status := parts[1] + filePath := parts[3] + + switch { + case key.Matches(msg, keys.Commit): + m.mode = modeInput + m.promptTitle = "Commit Message" + m.textInput.Placeholder = "Enter commit message" + m.textInput.Focus() + m.inputCallback = func(message string) tea.Cmd { + if message == "" { + // Don't commit with an empty message + return nil + } + return func() tea.Msg { + _, err := m.git.Commit(git.CommitOptions{Message: message}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + } + return nil + case key.Matches(msg, keys.StageItem): + // If the item is unstaged, stage it, and vice-versa. + isStaged := len(status) > 0 && status[0] != ' ' && status[0] != '?' + return func() tea.Msg { + var err error + if isStaged { + _, err = m.git.ResetFiles([]string{filePath}) + } else { + _, err = m.git.AddFiles([]string{filePath}) + } + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.StageAll): + return func() tea.Msg { + _, err := m.git.AddFiles([]string{"."}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.Reset): + return func() tea.Msg { + _, err := m.git.ResetFiles([]string{filePath}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.Discard): + return func() tea.Msg { + _, err := m.git.Restore(git.RestoreOptions{Paths: []string{filePath}, WorkingDir: true}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + } + return nil +} + +func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { + if handled, cmd := m.handleCursorMovement(msg); handled { + return cmd + } + + if m.panels[BranchesPanel].cursor >= len(m.panels[BranchesPanel].lines) { + return nil + } + line := m.panels[BranchesPanel].lines[m.panels[BranchesPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) < 2 { + return nil + } + branchName := strings.TrimSpace(strings.TrimPrefix(parts[1], "(*) → ")) + + switch { + case key.Matches(msg, keys.Checkout): + return func() tea.Msg { + _, err := m.git.Checkout(branchName) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.DeleteBranch): + m.mode = modeConfirm + m.confirmMessage = fmt.Sprintf("Are you sure you want to delete branch '%s'?", branchName) + m.confirmCallback = func(confirmed bool) tea.Cmd { + if !confirmed { + return nil + } + return func() tea.Msg { + _, err := m.git.ManageBranch(git.BranchOptions{Delete: true, Name: branchName}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + } + return nil + } + return nil +} + +func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { + if handled, cmd := m.handleCursorMovement(msg); handled { + return cmd + } + + if m.panels[CommitsPanel].cursor >= len(m.panels[CommitsPanel].lines) { + return nil + } + line := m.panels[CommitsPanel].lines[m.panels[CommitsPanel].cursor] + parts := strings.Split(line, "\t") + if len(parts) < 2 { + return nil + } + sha := parts[1] + + switch { + case key.Matches(msg, keys.Revert): + return func() tea.Msg { + _, err := m.git.Revert(sha) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + } + return nil +} + +func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { + if handled, cmd := m.handleCursorMovement(msg); handled { + return cmd + } + + if m.panels[StashPanel].cursor >= len(m.panels[StashPanel].lines) { + return nil + } + line := m.panels[StashPanel].lines[m.panels[StashPanel].cursor] + parts := strings.SplitN(line, "\t", 2) + if len(parts) < 1 { + return nil + } + stashID := parts[0] + + switch { + case key.Matches(msg, keys.StashApply): + return func() tea.Msg { + _, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.StashPop): + return func() tea.Msg { + _, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + case key.Matches(msg, keys.StashDrop): + return func() tea.Msg { + _, err := m.git.Stash(git.StashOptions{Drop: true, StashID: stashID}) + if err != nil { + return errMsg{err} + } + return fileWatcherMsg{} + } + } + return nil +} + +// handleFocusKeys changes the focused panel based on keyboard shortcuts. +func (m *Model) handleFocusKeys(msg tea.KeyMsg) { + switch { + case key.Matches(msg, keys.FocusNext): + m.nextPanel() + case key.Matches(msg, keys.FocusPrev): + m.prevPanel() + case key.Matches(msg, keys.FocusZero): + m.focusedPanel = MainPanel + case key.Matches(msg, keys.FocusOne): + m.focusedPanel = StatusPanel + case key.Matches(msg, keys.FocusTwo): + m.focusedPanel = FilesPanel + case key.Matches(msg, keys.FocusThree): + m.focusedPanel = BranchesPanel + case key.Matches(msg, keys.FocusFour): + m.focusedPanel = CommitsPanel + case key.Matches(msg, keys.FocusFive): + m.focusedPanel = StashPanel + case key.Matches(msg, keys.FocusSix): + m.focusedPanel = SecondaryPanel + } +} + +// recalculateLayout is the single source of truth for panel sizes and layout. +func (m Model) recalculateLayout() Model { + if m.width == 0 || m.height == 0 { + return m + } + + contentHeight := m.height - 1 // Account for help bar + m.panelHeights = make([]int, totalPanels) + expandedHeight := int(float64(contentHeight) * expandedPanelHeightRatio) + + // Right Column Layout + if m.focusedPanel == SecondaryPanel { + m.panelHeights[SecondaryPanel] = expandedHeight + m.panelHeights[MainPanel] = contentHeight - expandedHeight + } else { + m.panelHeights[SecondaryPanel] = collapsedPanelHeight + m.panelHeights[MainPanel] = contentHeight - collapsedPanelHeight + } + + // Left Column Layout + m.panelHeights[StatusPanel] = statusPanelHeight + remainingHeight := contentHeight - m.panelHeights[StatusPanel] + + if m.focusedPanel == StashPanel { + m.panelHeights[StashPanel] = expandedHeight + } else { + m.panelHeights[StashPanel] = collapsedPanelHeight + } + + flexiblePanels := []Panel{FilesPanel, BranchesPanel, CommitsPanel} + heightForFlex := remainingHeight - m.panelHeights[StashPanel] + focusedFlexPanelFound := false + for _, p := range flexiblePanels { + if p == m.focusedPanel { + focusedFlexPanelFound = true + break + } + } + + if focusedFlexPanelFound { + m.panelHeights[m.focusedPanel] = expandedHeight + heightForOthers := heightForFlex - expandedHeight + var otherPanels []Panel + for _, p := range flexiblePanels { + if p != m.focusedPanel { + otherPanels = append(otherPanels, p) + } + } + if len(otherPanels) > 0 { + share := heightForOthers / len(otherPanels) + for _, p := range otherPanels { + m.panelHeights[p] = share + } + // Distribute remainder pixels to the last panel. + m.panelHeights[otherPanels[len(otherPanels)-1]] += heightForOthers % len(otherPanels) + } + } else { + // Default distribution when no flexible panels are focused. + m.panelHeights[FilesPanel] = int(float64(heightForFlex) * 0.4) + m.panelHeights[BranchesPanel] = int(float64(heightForFlex) * 0.3) + m.panelHeights[CommitsPanel] = heightForFlex - m.panelHeights[FilesPanel] - m.panelHeights[BranchesPanel] + } + + return m.updateViewportSizes() +} + +// updateViewportSizes applies the calculated dimensions from the model to the viewports. +func (m Model) updateViewportSizes() Model { + leftSectionWidth := int(float64(m.width) * leftPanelWidthRatio) + rightSectionWidth := m.width - leftSectionWidth + rightContentWidth := rightSectionWidth - borderWidth + m.panels[MainPanel].viewport.Width = rightContentWidth + m.panels[MainPanel].viewport.Height = m.panelHeights[MainPanel] - titleBarHeight + m.panels[SecondaryPanel].viewport.Width = rightContentWidth + m.panels[SecondaryPanel].viewport.Height = m.panelHeights[SecondaryPanel] - titleBarHeight + + leftContentWidth := leftSectionWidth - borderWidth + leftPanels := []Panel{StatusPanel, FilesPanel, BranchesPanel, CommitsPanel, StashPanel} + for _, panel := range leftPanels { + m.panels[panel].viewport.Width = leftContentWidth + m.panels[panel].viewport.Height = m.panelHeights[panel] - titleBarHeight + } + return m +} + +// toggleHelp toggles the visibility of the help view. +func (m *Model) toggleHelp() { + m.showHelp = !m.showHelp + if m.showHelp { + m.styleHelpViewContent() + } +} + +// styleHelpViewContent prepares and styles the content for the help view. +func (m *Model) styleHelpViewContent() { + m.helpContent = m.generateHelpContent() + m.helpViewport.SetContent(m.helpContent) + m.helpViewport.GotoTop() +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..a0449ba --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,385 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" +) + +// ansiRegex is used to strip ANSI escape codes from strings. +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// stripAnsi removes ANSI escape codes from a string. +func stripAnsi(str string) string { + return ansiRegex.ReplaceAllString(str, "") +} + +// View is the main render function for the application. +func (m Model) View() string { + var finalView string + if m.showHelp { + finalView = m.renderHelpView() + } else { + finalView = m.renderMainView() + } + + // If not in normal mode, render a pop-up on top. + if m.mode != modeNormal { + var popup string + if m.mode == modeInput { + popup = m.renderInputPopup() + } else { // modeConfirm + popup = m.renderConfirmPopup() + } + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) + } + + return finalView +} + +// renderInputPopup creates the view for the text input pop-up. +func (m Model) renderInputPopup() string { + content := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.ActiveTitle.Render(" "+m.promptTitle+" "), + m.textInput.View(), + m.theme.InactiveTitle.Render(" (Enter to confirm, Esc to cancel) "), + ) + + return lipgloss.NewStyle(). + Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.ActiveBorder.Style.GetForeground()). + Render(content) +} + +// renderConfirmPopup creates the view for the confirmation pop-up. +func (m Model) renderConfirmPopup() string { + content := lipgloss.JoinVertical( + lipgloss.Left, + m.confirmMessage, + m.theme.InactiveTitle.Render(" (y/n) "), + ) + + return lipgloss.NewStyle(). + Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.theme.ActiveBorder.Style.GetForeground()). + Render(content) +} + +// renderMainView renders the primary user interface with all panels. +func (m Model) renderMainView() string { + if m.width == 0 || m.height == 0 || len(m.panelHeights) == 0 { + return initialContentLoading + } + + leftSectionWidth := int(float64(m.width) * leftPanelWidthRatio) + rightSectionWidth := m.width - leftSectionWidth + + leftpanels := []Panel{StatusPanel, FilesPanel, BranchesPanel, CommitsPanel, StashPanel} + rightpanels := []Panel{MainPanel, SecondaryPanel} + + titles := map[Panel]string{ + MainPanel: "Main", StatusPanel: "Status", FilesPanel: "Files", + BranchesPanel: "Branches", CommitsPanel: "Commits", StashPanel: "Stash", SecondaryPanel: "Secondary", + } + + leftColumn := m.renderPanelColumn(leftpanels, titles, leftSectionWidth) + rightColumn := m.renderPanelColumn(rightpanels, titles, rightSectionWidth) + + content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, rightColumn) + helpBar := m.renderHelpBar() + + finalView := lipgloss.JoinVertical(lipgloss.Bottom, content, helpBar) + zone.Scan(finalView) // Scan for mouse zones. + return finalView +} + +// renderPanelColumn renders a vertical stack of panels for one column. +func (m Model) renderPanelColumn(panels []Panel, titles map[Panel]string, width int) string { + var renderedPanels []string + for _, panel := range panels { + height := m.panelHeights[panel] + title := titles[panel] + renderedPanels = append(renderedPanels, m.renderPanel(title, width, height, panel)) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedPanels...) +} + +// renderPanel renders a single panel with its border, title, and content. +func (m Model) renderPanel(title string, width, height int, panel Panel) string { + isFocused := m.focusedPanel == panel + borderStyle := m.theme.InactiveBorder + titleStyle := m.theme.InactiveTitle + if isFocused { + borderStyle = m.theme.ActiveBorder + titleStyle = m.theme.ActiveTitle + } + + formattedTitle := fmt.Sprintf("[%d] %s", int(panel), title) + p := m.panels[panel] + + // Add item count to titles of selectable panels. + if panel == FilesPanel || panel == BranchesPanel || panel == CommitsPanel || panel == StashPanel { + if len(p.lines) > 0 { + formattedTitle = fmt.Sprintf("[%d] %s (%d/%d)", int(panel), title, p.cursor+1, len(p.lines)) + } + } + + content := p.content + contentWidth := width - borderWidth + + // For selectable panels, render each line individually. + if panel == FilesPanel || panel == BranchesPanel || panel == CommitsPanel || panel == StashPanel { + var builder strings.Builder + for i, line := range p.lines { + lineID := fmt.Sprintf("%s-line-%d", panel.ID(), i) + var finalLine string + + if i == p.cursor && isFocused { + var cleanLine string + // For the selected line, strip any existing ANSI codes before applying selection style. + if panel == FilesPanel { + // For files panel, don't show the hidden path in the selection. + parts := strings.Split(line, "\t") + if len(parts) >= 3 { + cleanLine = fmt.Sprintf("%s %s %s", parts[0], parts[1], parts[2]) + } else { + cleanLine = line + } + } else { + cleanLine = stripAnsi(line) + } + + cleanLine = strings.ReplaceAll(cleanLine, "\t", " ") // Also replace tabs + selectionStyle := m.theme.SelectedLine.Width(contentWidth) + finalLine = selectionStyle.Render(cleanLine) + } else { + styledLine := styleUnselectedLine(line, panel, m.theme) + finalLine = lipgloss.NewStyle().MaxWidth(contentWidth).Render(styledLine) + } + + builder.WriteString(zone.Mark(lineID, finalLine)) + builder.WriteRune('\n') + } + content = strings.TrimRight(builder.String(), "\n") + } + p.viewport.SetContent(content) + + isScrollable := !p.viewport.AtTop() || !p.viewport.AtBottom() + showScrollbar := isScrollable + // Conditionally hide scrollbar for certain panels when not focused. + if panel == StashPanel || panel == SecondaryPanel { + showScrollbar = isScrollable && isFocused + } + + box := renderBox( + formattedTitle, titleStyle, borderStyle, p.viewport, + m.theme.ScrollbarThumb, width, height, showScrollbar, + ) + return zone.Mark(panel.ID(), box) +} + +// renderHelpView renders the full-screen help view. +func (m Model) renderHelpView() string { + showScrollbar := !m.helpViewport.AtTop() || !m.helpViewport.AtBottom() + helpBox := renderBox( + "Help", + m.theme.ActiveTitle, + m.theme.ActiveBorder, + m.helpViewport, + m.theme.ScrollbarThumb, + m.helpViewport.Width, + m.helpViewport.Height, + showScrollbar, + ) + + centeredHelp := lipgloss.Place(m.width, m.height-1, lipgloss.Center, lipgloss.Center, helpBox) + helpBar := m.renderHelpBar() + return lipgloss.JoinVertical(lipgloss.Bottom, centeredHelp, helpBar) +} + +// renderHelpBar creates the help bar displayed at the bottom of the screen. +func (m Model) renderHelpBar() string { + var helpBindings []key.Binding + if !m.showHelp { + helpBindings = m.panelShortHelp() + } else { + helpBindings = keys.ShortHelp() + } + shortHelp := m.help.ShortHelpView(helpBindings) + helpButton := m.theme.HelpButton.Render(" help:? ") + markedButton := zone.Mark("help-button", helpButton) + return lipgloss.JoinHorizontal(lipgloss.Left, shortHelp, markedButton) +} + +// renderBox manually constructs a bordered box with a title and an integrated scrollbar. +func renderBox(title string, titleStyle lipgloss.Style, borderStyle BorderStyle, vp viewport.Model, thumbStyle lipgloss.Style, width, height int, showScrollbar bool) string { + contentLines := strings.Split(vp.View(), "\n") + contentWidth := width - borderWidth + contentHeight := height - titleBarHeight + if contentHeight < 0 { + contentHeight = 0 + } + + var builder strings.Builder + renderedTitle := titleStyle.Render(" " + title + " ") + builder.WriteString(borderStyle.Style.Render(borderStyle.TopLeft)) + builder.WriteString(renderedTitle) + remainingWidth := width - lipgloss.Width(renderedTitle) - 2 + if remainingWidth > 0 { + builder.WriteString(borderStyle.Style.Render(strings.Repeat(borderStyle.Top, remainingWidth))) + } + builder.WriteString(borderStyle.Style.Render(borderStyle.TopRight)) + builder.WriteRune('\n') + + var thumbPosition = -1 + if showScrollbar { + thumbPosition = int(float64(contentHeight-1) * vp.ScrollPercent()) + } + + for i := 0; i < contentHeight; i++ { + builder.WriteString(borderStyle.Style.Render(borderStyle.Left)) + if i < len(contentLines) { + builder.WriteString(lipgloss.NewStyle().MaxWidth(contentWidth).Render(contentLines[i])) + } else { + builder.WriteString(strings.Repeat(" ", contentWidth)) + } + + if thumbPosition == i { + builder.WriteString(thumbStyle.Render(scrollThumbChar)) + } else { + builder.WriteString(borderStyle.Style.Render(borderStyle.Right)) + } + builder.WriteRune('\n') + } + + builder.WriteString(borderStyle.Style.Render(borderStyle.BottomLeft)) + builder.WriteString(borderStyle.Style.Render(strings.Repeat(borderStyle.Bottom, width-2))) + builder.WriteString(borderStyle.Style.Render(borderStyle.BottomRight)) + + return builder.String() +} + +// generateHelpContent builds the formatted help string from the application's keymap. +func (m Model) generateHelpContent() string { + helpSections := keys.FullHelp() + var renderedSections []string + for _, section := range helpSections { + title := m.theme.HelpTitle. + MarginLeft(helpTitleMargin). + Render(strings.Join([]string{"---", section.Title, "---"}, " ")) + bindings := m.renderHelpSection(section.Bindings) + renderedSections = append(renderedSections, lipgloss.JoinVertical(lipgloss.Left, title, bindings)) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedSections...) +} + +// renderHelpSection formats a set of keybindings into a two-column layout. +func (m Model) renderHelpSection(bindings []key.Binding) string { + var helpText string + keyStyle := m.theme.HelpKey.Width(helpKeyWidth).Align(lipgloss.Right).MarginRight(helpDescMargin) + descStyle := lipgloss.NewStyle() + for _, kb := range bindings { + key := kb.Help().Key + desc := kb.Help().Desc + line := lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render(key), descStyle.Render(desc)) + helpText += line + "\n" + } + return helpText +} + +// styleUnselectedLine parses a raw data line and applies panel-specific styling. +func styleUnselectedLine(line string, panel Panel, theme Theme) string { + switch panel { + case FilesPanel: + parts := strings.Split(line, "\t") + if len(parts) < 3 { + return line + } + prefix, status, path := parts[0], parts[1], parts[2] + + var styledStatus string + if status == "" { + styledStatus = " " + } else { + styledStatus = styleStatus(status, theme) + } + return fmt.Sprintf("%s %s %s", prefix, styledStatus, path) + case BranchesPanel: + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + return line + } + date, name := parts[0], parts[1] + styledDate := theme.BranchDate.Render(date) + styledName := theme.NormalText.Render(name) + if strings.Contains(name, "(*)") { + styledName = theme.BranchCurrent.Render(name) + } + return lipgloss.JoinHorizontal(lipgloss.Left, styledDate, " ", styledName) + case CommitsPanel: + parts := strings.SplitN(line, "\t", 4) + if len(parts) != 4 { + // This is a graph-only line, already colored by git. + // We just replace the placeholder node with a styled one. + return strings.ReplaceAll(line, "○", theme.GraphNode.Render("○")) + } + graph, sha, author, subject := parts[0], parts[1], parts[2], parts[3] + + // The graph string is already colored by git, but we style the node. + styledGraph := strings.ReplaceAll(graph, "○", theme.GraphNode.Render("○")) + + // Apply our theme's styles to the other parts. + styledSHA := theme.CommitSHA.Render(sha) + styledAuthor := theme.CommitAuthor.Render(author) + if strings.HasPrefix(strings.ToLower(subject), "merge") { + styledAuthor = theme.CommitMerge.Render(author) + } + + final := lipgloss.JoinHorizontal(lipgloss.Left, styledSHA, " ", styledAuthor, " ", subject) + return fmt.Sprintf("%s %s", styledGraph, final) + case StashPanel: + parts := strings.SplitN(line, "\t", 2) + if len(parts) != 2 { + return line + } + name, message := parts[0], parts[1] + styledName := theme.StashName.Render(name) + styledMessage := theme.StashMessage.Render(message) + return lipgloss.JoinHorizontal(lipgloss.Left, styledName, " ", styledMessage) + } + return line +} + +// styleStatus takes a 2-character git status code and returns a styled string. +func styleStatus(status string, theme Theme) string { + if len(status) < 2 { + return " " + } + if status == "??" { + return theme.GitUntracked.Render(status) + } + indexChar := status[0] + workTreeChar := status[1] + if indexChar == 'U' || workTreeChar == 'U' || (indexChar == 'A' && workTreeChar == 'A') || (indexChar == 'D' && workTreeChar == 'D') { + return theme.GitConflicted.Render(status) + } + styledIndex := styleChar(indexChar, theme.GitStaged) + styledWorkTree := styleChar(workTreeChar, theme.GitUnstaged) + return styledIndex + styledWorkTree +} + +// styleChar styles a single character of a status code. +func styleChar(char byte, style lipgloss.Style) string { + if char == ' ' || char == '?' { + return " " + } + return style.Render(string(char)) +}