diff --git a/src/cmd/gh.go b/src/cmd/gh.go index b29de2c..c7ce7bc 100644 --- a/src/cmd/gh.go +++ b/src/cmd/gh.go @@ -1,13 +1,13 @@ -/* -Copyright © 2025 NAME HERE -*/ package cmd import ( "fmt" + "os" + "strings" "github.com/Slug-Boi/cocommit/src/cmd/tui" "github.com/Slug-Boi/cocommit/src/cmd/utils" + //"github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -20,7 +20,7 @@ func GHCmd () *cobra.Command { Long: `This command will create add a github profile to your author list. You just have to run the command with a username of the github profile you want to add. The email will be added manually by following the TUI or adding the email flag to the command.`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { email, _ := cmd.Flags().GetString("email") shortname, _ := cmd.Flags().GetString("shortname") @@ -29,6 +29,19 @@ func GHCmd () *cobra.Command { groups, _ := cmd.Flags().GetStringSlice("groups") exclude, _ := cmd.Flags().GetBool("exclude") + if len(args) == 0 { + username, email_out, err := tui.RunForm() + if err != nil { + panic(fmt.Sprintf("Error: %v", err)) + } + if username == "" { + os.Exit(0) + } + + args = append(args, username) + email = strings.TrimSpace(email_out) + } + user := utils.FetchGithubProfile(args[0]) // Update values if flags are set diff --git a/src/cmd/tui/tui_author.go b/src/cmd/tui/tui_author.go index 1d16075..119ff6d 100644 --- a/src/cmd/tui/tui_author.go +++ b/src/cmd/tui/tui_author.go @@ -50,7 +50,7 @@ func errorGetMissingFields(m model_ca) { } if len(m.inputs) > 0 { - for i := 0; i < inpLen-1; i++ { + for i := 0; i < inpLen; i++ { if m.inputs[i].Value() == "" { m.errorModel.missing = append(m.errorModel.missing, "- "+strings.Split(m.inputs[i].Placeholder," (")[0]) } @@ -137,6 +137,33 @@ func intitialErrorModel() *errorModel { } } +func createGHTempAuthorModel(old_m *model, user utils.User) model_ca { + parent_m = old_m + m := model_ca{ + inputs: make([]textinput.Model, 2), + errorModel: intitialErrorModel(), + } + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = cursorStyle + switch i { + case 0: + t.Placeholder = "Username (e.g. JohnDoe-gh)" + t.SetValue(user.Username) + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + case 1: + t.Placeholder = "Email (e.g. JohnDoe@domain.do)" + t.SetValue(user.Email) + } + m.inputs[i] = t + } + tempAuthorToggle = true + return m +} + func createGHAuthorModel(old_m *model, user utils.User) model_ca { parent_m = old_m @@ -249,8 +276,9 @@ func (m model_ca) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": + tempAuthorToggle = false m.inputs = nil - if parent_m.keys != nil { + if parent_m != nil { return nil, nil } return m, tea.Quit @@ -268,9 +296,9 @@ func (m model_ca) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = false return m, nil } - if parent_m.keys != nil { + if parent_m != nil { return model{list: parent_m.list}, tea.ClearScreen - } else { + } else { m.quitting = true return m, tea.Quit } @@ -288,9 +316,11 @@ func (m model_ca) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if parent_m.keys != nil { + tempAuthorToggle = false return model{list: parent_m.list}, tea.ClearScreen } else { m.quitting = true + tempAuthorToggle = false return m, tea.Quit } } @@ -303,10 +333,15 @@ func (m model_ca) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusIndex++ } - if m.focusIndex > len(m.inputs)+1 { + inpNum := len(m.inputs) + if !tempAuthorToggle { + inpNum++ + } + + if m.focusIndex > inpNum { m.focusIndex = 0 } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) + m.focusIndex = inpNum } cmds := make([]tea.Cmd, len(m.inputs)) @@ -427,7 +462,7 @@ func (m *model_ca) AddAuthor() bool { author := m.inputs[0].Value() - if parent_m.keys != nil { + if parent_m != nil { item_str := utils.Users[author].Username + " - " + utils.Users[author].Email dupProtect[item_str] = author parent_m.list.InsertItem(len(parent_m.list.Items())+1, item(item_str)) diff --git a/src/cmd/tui/tui_github.go b/src/cmd/tui/tui_github.go new file mode 100644 index 0000000..eadea72 --- /dev/null +++ b/src/cmd/tui/tui_github.go @@ -0,0 +1,226 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/Slug-Boi/cocommit/src/cmd/utils" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Styles +var ( + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + toggleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")) + activeToggleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) +) + +type GitHubUserModel struct { + inputs []textinput.Model + focusIndex int + submitted bool + showError bool + errorMsg string + tempAuthShow bool + tempAuth bool +} + +func NewGitHubUserForm(old_m *model) GitHubUserModel { + parent_m = old_m + + m := GitHubUserModel{ + inputs: make([]textinput.Model, 2), + tempAuthShow: func() bool { + return old_m != nil + }(), + + } + + // GitHub Username (required) + username := textinput.New() + username.Placeholder = "GitHub username *" + username.PromptStyle = focusedStyle + username.TextStyle = focusedStyle + username.Focus() + username.CharLimit = 39 // GitHub username max length + m.inputs[0] = username + + // Email (optional) + email := textinput.New() + email.Placeholder = "Email" + email.PromptStyle = blurredStyle + email.TextStyle = blurredStyle + m.inputs[1] = email + + return m +} + +func (m GitHubUserModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m GitHubUserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + if parent_m != nil { + return nil, nil + } + return m, tea.Quit + case "ctrl+t": // Toggle temp mode + if m.tempAuthShow { + m.tempAuth = !m.tempAuth + return m, nil + } + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Submit on enter when button is focused + if s == "enter" && m.focusIndex == len(m.inputs)+1 && m.tempAuthShow || s == "enter" && m.focusIndex == len(m.inputs) && !m.tempAuthShow { + if m.inputs[0].Value() == "" { + m.showError = true + m.errorMsg = "GitHub username is required" + return m, nil + } + m.submitted = true + user := utils.FetchGithubProfile(m.inputs[0].Value()) + if m.inputs[1].Value() != "" { + user.Email = m.inputs[1].Value() + } + if m.tempAuth { + return createGHTempAuthorModel(parent_m,user), tea.ClearScreen + } + return createGHAuthorModel(parent_m,user), tea.ClearScreen + + } else if s == "enter" && m.focusIndex == len(m.inputs) && m.tempAuthShow { + //toggle temp mode + m.tempAuth = !m.tempAuth + return m, nil + } + + + // Cycle through inputs + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + inpNum := len(m.inputs) + if m.tempAuthShow { + inpNum++ + } + + if m.focusIndex > inpNum { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = inpNum + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i < len(m.inputs); i++ { + if i == m.focusIndex { + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + continue + } + m.inputs[i].Blur() + m.inputs[i].PromptStyle = blurredStyle + if m.inputs[i].Value() == "" { + m.inputs[i].TextStyle = blurredStyle + } else { + m.inputs[i].TextStyle = noStyle + } + } + + m.showError = false // Clear error when navigating + return m, tea.Batch(cmds...) + } + } + + // Handle text input + cmd := m.updateInputs(msg) + return m, cmd +} + +func (m *GitHubUserModel) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m GitHubUserModel) View() string { + if m.submitted { + return "" + } + + var b strings.Builder + + // Title + b.WriteString("Enter GitHub User Details\n\n") + + // Input fields + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + + if m.tempAuthShow { + toggleText := "[ ]" + if m.tempAuth { + toggleText = "[X]" + } + + toggleBtn := fmt.Sprintf("[ TempAuthor ] %s ", toggleText) + + if m.focusIndex == len(m.inputs) { // When toggle is focused + b.WriteString("\n" + focusedStyle.Render(toggleBtn)) + } else { + b.WriteString("\n" + blurredStyle.Render(toggleBtn)) + } + } + + // Submit button + button := blurredButton + if m.focusIndex == len(m.inputs)+1 && m.tempAuthShow || m.focusIndex == len(m.inputs) && !m.tempAuthShow { + button = focusedButton + } + b.WriteString("\n\n" + button + "\n") + + // Error message + if m.showError { + b.WriteString("\n" + errorStyle.Render(m.errorMsg) + "\n") + } + + // Help text + b.WriteString("\n" + blurredStyle.Render("tab to navigate • enter to submit")) + + return b.String() +} + +// RunForm starts the TUI and returns the entered values +func RunForm() (string, string, error) { + model := NewGitHubUserForm(nil) + p := tea.NewProgram(model) + + m, err := p.Run() + if err != nil { + return "", "", err + } + + if fm, ok := m.(GitHubUserModel); ok { + if fm.submitted { + return fm.inputs[0].Value(), fm.inputs[1].Value(), nil + } + } + + return "", "", nil +} \ No newline at end of file diff --git a/src/cmd/tui/tui_list.go b/src/cmd/tui/tui_list.go index 29884ff..3247622 100644 --- a/src/cmd/tui/tui_list.go +++ b/src/cmd/tui/tui_list.go @@ -47,6 +47,7 @@ type listKeyMap struct { createAuthor key.Binding deleteAuthor key.Binding tempAdd key.Binding + ghAdd key.Binding } func newListKeyMap() *listKeyMap { @@ -79,6 +80,10 @@ func newListKeyMap() *listKeyMap { key.WithKeys("T"), key.WithHelp("T", "Add temporary author"), ), + ghAdd: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "Add GitHub author"), + ), } } @@ -180,6 +185,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Handle keys from keyList (help menu) switch { + case key.Matches(msg, m.keys.ghAdd): + sub_model = NewGitHubUserForm(&m) + return m, tea.ClearScreen + case key.Matches(msg, m.keys.negation): i, ok := m.list.SelectedItem().(item) if ok { diff --git a/src/cmd/tui/tui_test.go b/src/cmd/tui/tui_test.go index ff2302a..c5f3950 100644 --- a/src/cmd/tui/tui_test.go +++ b/src/cmd/tui/tui_test.go @@ -332,7 +332,6 @@ func TestModelCAInit(t *testing.T) { } } - func TestCreateGHAuthorModel(t *testing.T) { setup() defer teardown() @@ -377,6 +376,148 @@ func TestCreateGHAuthorModel(t *testing.T) { } } +func TestNewGitHubUserForm(t *testing.T) { + model := NewGitHubUserForm(nil) + + if len(model.inputs) != 2 { + t.Errorf("Expected 2 input fields, got %d", len(model.inputs)) + } + + if model.inputs[0].Placeholder != "GitHub username *" { + t.Errorf("First input placeholder incorrect") + } + + if model.tempAuthShow { + t.Error("tempAuthShow should be false when no parent model provided") + } +} + +// Test form submission with required field +func TestSubmitWithRequiredField(t *testing.T) { + setup() + defer teardown() + + + m := NewGitHubUserForm(nil) + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + + // Simulate filling in the required field + tm.Type("Slug-Boi") + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) // Move to next field + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) // Move to submit button + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Submit + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Type("input@mail") + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Send(tea.KeyMsg{Type: tea.KeyTab}) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Submit + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*5)) + // Check if the form was submitted + updated, _ := tm.FinalModel(t).(model_ca) + + + + if updated.inputs[0].Value() != "th" { + t.Errorf("Expected 'Slug-Boi', got '%s'", updated.inputs[0].Value()) + } + if updated.inputs[1].Value() != "Theis" { + t.Errorf("Expected 'Slug-Boi', got '%s'", updated.inputs[1].Value()) + } + if updated.inputs[2].Value() != "Slug-Boi" { + t.Errorf("Expected 'Slug-Boi', got '%s'", updated.inputs[2].Value()) + } + if updated.inputs[3].Value() != "input@mail" { + t.Errorf("Expected 'input@mail', got '%s'", updated.inputs[3].Value()) + } +} + +// Test temp auth toggle visibility +func TestTempAuthToggleVisibility(t *testing.T) { + // With parent model (should show toggle) + m1 := NewGitHubUserForm(&model{}) + if !m1.tempAuthShow { + t.Error("tempAuthShow should be true with parent model") + } + + // Without parent model (should hide toggle) + m2 := NewGitHubUserForm(nil) + if m2.tempAuthShow { + t.Error("tempAuthShow should be false without parent model") + } +} + +// Test temp auth toggle functionality +func TestTempAuthToggle(t *testing.T) { + m := NewGitHubUserForm(&model{}) + + // Initial state + if m.tempAuth { + t.Error("tempAuth should be false initially") + } + + // Toggle on + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + if !updated.(GitHubUserModel).tempAuth { + t.Error("Ctrl+T should toggle tempAuth to true") + } + + // Toggle off + updated, _ = updated.(GitHubUserModel).Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + if updated.(GitHubUserModel).tempAuth { + t.Error("Ctrl+T should toggle tempAuth to false") + } +} + +// Test navigation between fields +func TestFieldNavigation(t *testing.T) { + m := NewGitHubUserForm(nil) + + // Initial focus should be on username + if m.focusIndex != 0 { + t.Error("Initial focus should be on username field") + } + + // Tab to email + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + if updated.(GitHubUserModel).focusIndex != 1 { + t.Error("Tab should move focus to email field") + } + + // Tab to submit + updated, _ = updated.(GitHubUserModel).Update(tea.KeyMsg{Type: tea.KeyTab}) + if updated.(GitHubUserModel).focusIndex != 2 { + t.Error("Tab should move focus to submit button") + } +} + +// Test view rendering +func TestViewRendering(t *testing.T) { + m := NewGitHubUserForm(nil) + view := m.View() + + if !strings.Contains(view, "GitHub username *") { + t.Error("View should render username field") + } + + if !strings.Contains(view, "tab to navigate") { + t.Error("View should render help text") + } + + // Test error message rendering + m.showError = true + m.errorMsg = "Test error" + errorView := m.View() + if !strings.Contains(errorView, "Test error") { + t.Error("View should render error message") + } +} + // tui_author TESTS END // tui_commit_message TESTS BEGIN diff --git a/src/cmd/utils/util_test.go b/src/cmd/utils/util_test.go index a3431e0..af52499 100644 --- a/src/cmd/utils/util_test.go +++ b/src/cmd/utils/util_test.go @@ -556,6 +556,26 @@ func Test_GitWrapper(t *testing.T) { defer teardown() utils.Define_users("author_file_test") + // create a temporary file to test git wrapper + tmpFile, err := os.CreateTemp("", "test_git_wrapper") + if err != nil { + t.Fatalf("Failed to create temporary file: %v", err) + } + + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + // Write some content to the temporary file + _, err = tmpFile.WriteString("Test content") + if err != nil { + t.Fatalf("Failed to write to temporary file: %v", err) + } + + // Close the file to flush the content + err = tmpFile.Close() + if err != nil { + t.Fatalf("Failed to close temporary file: %v", err) + } // Test GitWrapper with --dry-run flag authors := []string{"te"} @@ -564,7 +584,7 @@ func Test_GitWrapper(t *testing.T) { commit := utils.Commit(message, authors) flags := []string{"-a","--dry-run"} - err := utils.GitWrapper(commit, flags) + err = utils.GitWrapper(commit, flags) if err != nil { t.Errorf("GitWrapper() returned error: %v", err) }