diff --git a/cmd/fork-cleaner/main.go b/cmd/fork-cleaner/main.go index 350a76c..21253a4 100644 --- a/cmd/fork-cleaner/main.go +++ b/cmd/fork-cleaner/main.go @@ -69,7 +69,7 @@ func main() { return cli.Exit("missing github token", 1) } - p := tea.NewProgram(ui.NewInitialModel(client, login), tea.WithAltScreen()) + p := tea.NewProgram(ui.NewAppModel(client, login), tea.WithAltScreen()) if _, err = p.Run(); err != nil { return cli.Exit(err.Error(), 1) } diff --git a/go.mod b/go.mod index 68ce86f..107fe26 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,19 @@ module github.com/caarlos0/fork-cleaner/v2 go 1.20 require ( + github.com/caarlos0/timea.go v1.0.2 github.com/charmbracelet/bubbles v0.15.0 github.com/charmbracelet/bubbletea v0.23.2 + github.com/charmbracelet/lipgloss v0.7.1 github.com/google/go-github/v50 v50.2.0 - github.com/muesli/termenv v0.15.1 github.com/urfave/cli/v2 v2.25.1 golang.org/x/oauth2 v0.6.0 ) require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/lipgloss v0.7.1 // indirect github.com/cloudflare/circl v1.1.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect @@ -27,8 +28,10 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/crypto v0.7.0 // indirect golang.org/x/net v0.8.0 // indirect diff --git a/go.sum b/go.sum index 5dc2bd7..8b610ad 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,14 @@ github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +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 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 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/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/caarlos0/timea.go v1.0.2 h1:TTwrLOvn71SnLSq613h9Q9pdujOzrXXxMinNEqmpNso= +github.com/caarlos0/timea.go v1.0.2/go.mod h1:MyDHBpPAvgjxyCJDk1B/LWhBVCWoTrVhyZZ+rjAcxWA= github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= @@ -32,9 +35,12 @@ github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUe github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -66,6 +72,7 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..dcab6cc --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,138 @@ +package ui + +import ( + "log" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-github/v50/github" +) + +// AppModel is the UI when the CLI starts, basically loading the repos. +type AppModel struct { + err error + login string + client *github.Client + list list.Model +} + +// NewAppModel creates a new AppModel with required fields. +func NewAppModel(client *github.Client, login string) AppModel { + list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + list.Title = "Fork Cleaner" + list.SetSpinner(spinner.MiniDot) + list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + keySelectToggle, + keyDeletedSelected, + } + } + list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + keySelectAll, + keySelectNone, + } + } + + return AppModel{ + client: client, + login: login, + list: list, + } +} + +func (m AppModel) Init() tea.Cmd { + return tea.Batch(enqueueGetReposCmd, m.list.StartSpinner()) +} + +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + log.Println("tea.WindowSizeMsg") + top, right, bottom, left := listStyle.GetMargin() + m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) + case errMsg: + log.Println("errMsg") + m.err = msg.error + case getRepoListMsg: + log.Println("getRepoListMsg") + cmds = append(cmds, m.list.StartSpinner(), getReposCmd(m.client, m.login)) + case gotRepoListMsg: + log.Println("gotRepoListMsg") + m.list.StopSpinner() + cmds = append(cmds, m.list.SetItems(reposToItems(msg.repos))) + case reposDeletedMsg: + log.Println("reposDeletedMsg") + cmds = append(cmds, m.list.StartSpinner(), enqueueGetReposCmd) + case requestDeleteSelectedReposMsg: + log.Println("requestDeleteSelectedReposMsg") + selected, unselected := splitBySelection(m.list.Items()) + cmds = append( + cmds, + m.list.SetItems(reposToItems(unselected)), + deleteReposCmd(m.client, selected), + ) + + case tea.KeyMsg: + if m.list.SettingFilter() { + break + } + + if key.Matches(msg, keySelectAll) { + log.Println("tea.KeyMsg -> selectAll") + cmds = append(cmds, m.changeSelect(true)...) + } + + if key.Matches(msg, keySelectNone) { + log.Println("tea.KeyMsg -> selectNone") + cmds = append(cmds, m.changeSelect(false)...) + } + + if key.Matches(msg, keySelectToggle) { + log.Println("tea.KeyMsg -> selectToggle") + cmds = append(cmds, m.toggleSelection()) + } + + if key.Matches(msg, keyDeletedSelected) { + log.Println("tea.KeyMsg -> deleteSelected") + cmds = append(cmds, m.list.StartSpinner(), requestDeleteReposCmd) + } + } + + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m AppModel) View() string { + if m.err != nil { + return errorStyle.Bold(true).Render("Error gathering the repository list") + + "\n" + + errorStyle.Render(m.err.Error()) + } + return m.list.View() +} + +func (m AppModel) toggleSelection() tea.Cmd { + idx := m.list.Index() + item := m.list.SelectedItem().(item) + item.selected = !item.selected + m.list.RemoveItem(idx) + return m.list.InsertItem(idx, item) +} + +func (m AppModel) changeSelect(selected bool) []tea.Cmd { + var cmds []tea.Cmd + for idx, i := range m.list.Items() { + item := i.(item) + item.selected = selected + m.list.RemoveItem(idx) + cmds = append(cmds, m.list.InsertItem(idx, item)) + } + return cmds +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..0cbeb27 --- /dev/null +++ b/internal/ui/commands.go @@ -0,0 +1,44 @@ +package ui + +import ( + "context" + "log" + "strings" + + forkcleaner "github.com/caarlos0/fork-cleaner/v2" + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-github/v50/github" +) + +func requestDeleteReposCmd() tea.Msg { + return requestDeleteSelectedReposMsg{} +} + +func deleteReposCmd(client *github.Client, repos []*forkcleaner.RepositoryWithDetails) tea.Cmd { + return func() tea.Msg { + var names []string + for _, r := range repos { + names = append(names, r.Name) + } + log.Println("deleteReposCmd", strings.Join(names, ", ")) + if err := forkcleaner.Delete(context.Background(), client, repos); err != nil { + return errMsg{err} + } + return reposDeletedMsg{} + } +} + +func enqueueGetReposCmd() tea.Msg { + return getRepoListMsg{} +} + +func getReposCmd(client *github.Client, login string) tea.Cmd { + return func() tea.Msg { + log.Println("getReposCmd") + repos, err := forkcleaner.FindAllForks(context.Background(), client, login) + if err != nil { + return errMsg{err} + } + return gotRepoListMsg{repos} + } +} diff --git a/internal/ui/common.go b/internal/ui/common.go deleted file mode 100644 index 767c725..0000000 --- a/internal/ui/common.go +++ /dev/null @@ -1,60 +0,0 @@ -package ui - -import ( - "fmt" - - "github.com/muesli/termenv" -) - -var ( - primary = termenv.ColorProfile().Color("205") - secondary = termenv.ColorProfile().Color("#89F0CB") - gray = termenv.ColorProfile().Color("#626262") - midGray = termenv.ColorProfile().Color("#4A4A4A") - red = termenv.ColorProfile().Color("#ED567A") -) - -const ( - iconSelected = "●" - iconNotSelected = "○" -) - -func boldPrimaryForeground(s string) string { - return termenv.String(s).Foreground(primary).Bold().String() -} - -func boldSecondaryForeground(s string) string { - return termenv.String(s).Foreground(secondary).Bold().String() -} - -func boldRedForeground(s string) string { - return termenv.String(s).Foreground(red).Bold().String() -} - -func redForeground(s string) string { - return termenv.String(s).Foreground(red).String() -} - -func redFaintForeground(s string) string { - return termenv.String(s).Foreground(red).Faint().String() -} - -func grayForeground(s string) string { - return termenv.String(s).Foreground(gray).String() -} - -func midGrayForeground(s string) string { - return termenv.String(s).Foreground(midGray).String() -} - -func faint(s string) string { - return termenv.String(s).Faint().String() -} - -type errMsg struct{ error } - -func (e errMsg) Error() string { return e.error.Error() } - -func errorView(action string, err error) string { - return redForeground(fmt.Sprintf(action+": %s.\nCheck the log file for more details.", err.Error())) + singleOptionHelp("q", "quit") -} diff --git a/internal/ui/deleted.go b/internal/ui/deleted.go deleted file mode 100644 index 74313bc..0000000 --- a/internal/ui/deleted.go +++ /dev/null @@ -1,54 +0,0 @@ -package ui - -import ( - "strconv" - - tea "github.com/charmbracelet/bubbletea" -) - -// NewDeleteEndModelSucceed creates a DeleteEndModel with a success result. -func NewDeleteEndModelSucceed(deleted int) DeleteEndModel { - return DeleteEndModel{ - deleted: deleted, - } -} - -// NewDeleteEndModelFailed creates a DeleteEndModel with a failed result. -func NewDeleteEndModelFailed(err error) DeleteEndModel { - return DeleteEndModel{ - err: err, - } -} - -// DeleteEndModel is the UI for when the forks were either deleted or failed -// to do so. -type DeleteEndModel struct { - err error - deleted int -} - -func (m DeleteEndModel) Init() tea.Cmd { - return nil -} - -func (m DeleteEndModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit - } - } - return m, nil -} - -func (m DeleteEndModel) View() string { - if m.deleted > 0 { - return redFaintForeground("Successfully deleted ") + redForeground(strconv.Itoa(m.deleted)) + redFaintForeground(" forks.") + - singleOptionHelp("q", "quit") - } - if m.err != nil { - return errorView("Error deleting repositories", m.err) - } - return "" -} diff --git a/internal/ui/deleting.go b/internal/ui/deleting.go deleted file mode 100644 index b57ef60..0000000 --- a/internal/ui/deleting.go +++ /dev/null @@ -1,103 +0,0 @@ -package ui - -import ( - "context" - - forkcleaner "github.com/caarlos0/fork-cleaner/v2" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-github/v50/github" -) - -// NewDeletingModel creates a DeletingModel with required fields. -func NewDeletingModel(client *github.Client, repos []*forkcleaner.RepositoryWithDetails, previous ListModel) DeletingModel { - s := spinner.New() - s.Spinner = spinner.MiniDot - - return DeletingModel{ - client: client, - repos: repos, - spinner: s, - previous: previous, - } -} - -// DeletingModel is the UI in which the user can review the repos they -// selected to be deleted and either finally delete them or cancel. -type DeletingModel struct { - client *github.Client - repos []*forkcleaner.RepositoryWithDetails - cursor int - spinner spinner.Model - loading bool - previous ListModel -} - -func (m DeletingModel) Init() tea.Cmd { - return nil -} - -func (m DeletingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case reposDeletedMsg: - return NewDeleteEndModelSucceed(msg.total), nil - case errMsg: - return NewDeleteEndModelFailed(msg.error), nil - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "q", "esc", "n": - return m.previous, m.previous.Init() - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.repos)-1 { - m.cursor++ - } - case "y": - m.loading = true - return m, tea.Batch(deleteRepos(m.client, m.repos), m.spinner.Tick) - } - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - return m, nil -} - -func (m DeletingModel) View() string { - if m.loading { - return redFaintForeground(m.spinner.View()) + redForeground(" Deleting repositories...") - } - - s := redForeground("Are you sure you want to delete the selected repositories? (y/N)\n\n") - for i, repo := range m.repos { - line := faint(iconSelected+" "+repo.Name) + "\n" - if m.cursor == i { - line = "\n" + boldRedForeground(line) + viewRepositoryDetails(repo) - } - s += line - } - return s + helpView([]helpOption{ - {"q/esc/n", "abort", true}, - {"up/down", "navigate", false}, - {"y", "delete items", false}, - }) -} - -type reposDeletedMsg struct { - total int -} - -func deleteRepos(client *github.Client, repos []*forkcleaner.RepositoryWithDetails) tea.Cmd { - return func() tea.Msg { - if err := forkcleaner.Delete(context.Background(), client, repos); err != nil { - return errMsg{err} - } - return reposDeletedMsg{len(repos)} - } -} diff --git a/internal/ui/help.go b/internal/ui/help.go deleted file mode 100644 index af88807..0000000 --- a/internal/ui/help.go +++ /dev/null @@ -1,43 +0,0 @@ -package ui - -import ( - "strings" - - "github.com/muesli/termenv" -) - -func singleOptionHelp(k, v string) string { - return helpView([]helpOption{ - {k, v, true}, - }) -} - -var separator = midGrayForeground(" • ") - -func helpView(options []helpOption) string { - var lines []string - - var line []string - for i, help := range options { - if help.primary { - line = append(line, grayForeground(help.key)+" "+termenv.String(help.help).Foreground(secondary).Faint().String()) - } else { - line = append(line, grayForeground(help.key)+" "+midGrayForeground(help.help)) - } - // splits in rows of 3 options max - if (i+1)%3 == 0 { - lines = append(lines, strings.Join(line, separator)) - line = []string{} - } - } - - // append remainder - lines = append(lines, strings.Join(line, separator)) - - return "\n\n" + strings.Join(lines, "\n") -} - -type helpOption struct { - key, help string - primary bool -} diff --git a/internal/ui/initial.go b/internal/ui/initial.go deleted file mode 100644 index 3f0803e..0000000 --- a/internal/ui/initial.go +++ /dev/null @@ -1,82 +0,0 @@ -package ui - -import ( - "context" - - forkcleaner "github.com/caarlos0/fork-cleaner/v2" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-github/v50/github" -) - -// NewInitialModel creates a new InitialModel with required fields. -func NewInitialModel(client *github.Client, login string) InitialModel { - s := spinner.New() - s.Spinner = spinner.MiniDot - - return InitialModel{ - client: client, - login: login, - spinner: s, - loading: true, - } -} - -// InitialModel is the UI when the CLI starts, basically loading the repos. -type InitialModel struct { - err error - login string - client *github.Client - spinner spinner.Model - loading bool -} - -func (m InitialModel) Init() tea.Cmd { - return tea.Batch(getRepos(m.client, m.login), m.spinner.Tick) -} - -func (m InitialModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case errMsg: - m.loading = false - m.err = msg.error - return m, nil - case gotRepoListMsg: - list := NewListModel(m.client, msg.repos) - return list, list.Init() - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit - } - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - return m, nil -} - -func (m InitialModel) View() string { - if m.loading { - return boldPrimaryForeground(m.spinner.View()) + " Gathering repositories..." + singleOptionHelp("q", "quit") - } - if m.err != nil { - return errorView("Error gathering the repository list", m.err) - } - return "" -} - -type gotRepoListMsg struct { - repos []*forkcleaner.RepositoryWithDetails -} - -func getRepos(client *github.Client, login string) tea.Cmd { - return func() tea.Msg { - repos, err := forkcleaner.FindAllForks(context.Background(), client, login) - if err != nil { - return errMsg{err} - } - return gotRepoListMsg{repos} - } -} diff --git a/internal/ui/item.go b/internal/ui/item.go new file mode 100644 index 0000000..5057882 --- /dev/null +++ b/internal/ui/item.go @@ -0,0 +1,90 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + forkcleaner "github.com/caarlos0/fork-cleaner/v2" + timeago "github.com/caarlos0/timea.go" + "github.com/charmbracelet/bubbles/list" +) + +type item struct { + repo *forkcleaner.RepositoryWithDetails + selected bool +} + +func (i item) Title() string { + var forked string + if i.repo.ParentName != "" { + forked = fmt.Sprintf(" (forked from %s)", i.repo.ParentName) + } + if i.selected { + return iconSelected + " " + i.repo.Name + forked + } + return iconNotSelected + " " + i.repo.Name + forked +} + +func (i item) Description() string { + repo := i.repo + var details []string + if repo.ParentDeleted { + details = append(details, "parent was deleted") + } + if repo.ParentDMCATakeDown { + details = append(details, "parent was taken down by DMCA") + } + if repo.Private { + details = append(details, "is private") + } + if repo.CommitsAhead > 0 { + details = append(details, fmt.Sprintf("%d commit%s ahead", repo.CommitsAhead, maybePlural(repo.CommitsAhead))) + } + if repo.Forks > 0 { + details = append(details, fmt.Sprintf("has %d fork%s", repo.Forks, maybePlural(repo.Forks))) + } + if repo.Stars > 0 { + details = append(details, fmt.Sprintf("has %d star%s", repo.Stars, maybePlural(repo.Stars))) + } + if repo.OpenPRs > 0 { + details = append(details, fmt.Sprintf("has %d open PR%s to upstream", repo.OpenPRs, maybePlural(repo.OpenPRs))) + } + if time.Now().Add(-30 * 24 * time.Hour).Before(repo.LastUpdate) { + details = append(details, fmt.Sprintf("recently updated (%s)", timeago.Of(repo.LastUpdate))) + } + + return detailsStyle.Render(strings.Join(details, separator)) +} + +func maybePlural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func (i item) FilterValue() string { return " " + i.repo.Name } + +func splitBySelection(items []list.Item) ([]*forkcleaner.RepositoryWithDetails, []*forkcleaner.RepositoryWithDetails) { + var selected, unselected []*forkcleaner.RepositoryWithDetails + for _, it := range items { + item := it.(item) + if item.selected { + selected = append(selected, item.repo) + } else { + unselected = append(unselected, item.repo) + } + } + return selected, unselected +} + +func reposToItems(repos []*forkcleaner.RepositoryWithDetails) []list.Item { + var items = make([]list.Item, 0, len(repos)) + for _, repo := range repos { + items = append(items, item{ + repo: repo, + }) + } + return items +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..c93f1ce --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,10 @@ +package ui + +import "github.com/charmbracelet/bubbles/key" + +var ( + keySelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")) + keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) + keySelectToggle = key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle selected item")) + keyDeletedSelected = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete selected forks")) +) diff --git a/internal/ui/list.go b/internal/ui/list.go deleted file mode 100644 index af7b2db..0000000 --- a/internal/ui/list.go +++ /dev/null @@ -1,156 +0,0 @@ -package ui - -import ( - "fmt" - "time" - - forkcleaner "github.com/caarlos0/fork-cleaner/v2" - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-github/v50/github" - "github.com/muesli/termenv" -) - -// NewListModel creates a new ListModel with the required fields. -func NewListModel(client *github.Client, repos []*forkcleaner.RepositoryWithDetails) ListModel { - return ListModel{ - client: client, - repos: repos, - selected: map[int]struct{}{}, - } -} - -// ListModel is the UI in which the user can select which forks should be -// deleted if any, and see details on each of them. -type ListModel struct { - client *github.Client - repos []*forkcleaner.RepositoryWithDetails - cursor int - selected map[int]struct{} -} - -func (m ListModel) Init() tea.Cmd { - return nil -} - -func (m ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.repos)-1 { - m.cursor++ - } - case "a": - for i := range m.repos { - m.selected[i] = struct{}{} - } - case "n": - for i := range m.selected { - delete(m.selected, i) - } - case " ": - _, ok := m.selected[m.cursor] - if ok { - delete(m.selected, m.cursor) - } else { - m.selected[m.cursor] = struct{}{} - } - case "d": - var deleteable []*forkcleaner.RepositoryWithDetails - for k := range m.selected { - deleteable = append(deleteable, m.repos[k]) - } - dm := NewDeletingModel(m.client, deleteable, m) - return dm, dm.Init() - } - } - return m, nil -} - -func (m ListModel) View() string { - s := boldSecondaryForeground("Which of these forks do you want to delete?\n\n") - - for i, repo := range m.repos { - line := repo.Name - if _, ok := m.selected[i]; ok { - line = iconSelected + " " + line - } else { - line = faint(iconNotSelected + " " + line) - } - line += "\n" - - if m.cursor == i { - nl := "" - if i > 0 { - nl = "\n" - } - line = nl + boldPrimaryForeground(line) + viewRepositoryDetails(repo) - } - - s += line - } - - return s + helpView([]helpOption{ - {"up/down", "navigate", false}, - {"space", "toggle selection", false}, - {"d", "delete selected", true}, - {"a", "select all", false}, - {"n", "deselect all", false}, - {"q/esc", "quit", false}, - }) -} - -func viewRepositoryDetails(repo *forkcleaner.RepositoryWithDetails) string { - var details []string - if repo.ParentName != "" { - details = append(details, fmt.Sprintf("Forked from %s", repo.ParentName)) - } - if repo.ParentDeleted { - details = append(details, "Parent repository was deleted or there are no common ancestors") - } - if repo.ParentDMCATakeDown { - details = append(details, "Parent repository was taken down by DMCA") - } - if repo.Private { - details = append(details, "Is private") - } - if repo.CommitsAhead > 0 { - details = append(details, fmt.Sprintf("Has %d commit%s ahead of parent", repo.CommitsAhead, maybePlural(repo.CommitsAhead))) - } - if repo.Forks > 0 { - details = append(details, fmt.Sprintf("Has %d fork%s", repo.Forks, maybePlural(repo.Forks))) - } - if repo.Stars > 0 { - details = append(details, fmt.Sprintf("Has %d star%s", repo.Stars, maybePlural(repo.Stars))) - } - if repo.OpenPRs > 0 { - details = append(details, fmt.Sprintf("Has %d open PR%s on parent", repo.OpenPRs, maybePlural(repo.OpenPRs))) - } - if time.Now().Add(-30 * 24 * time.Hour).Before(repo.LastUpdate) { - details = append(details, fmt.Sprintf("Was updated recently (%s)", repo.LastUpdate)) - } - - if len(details) == 0 { - return "" - } - - var s string - for _, d := range details { - s += " * " + d + "\n" - } - s += "\n" - return termenv.String(s).Faint().Italic().String() -} - -func maybePlural(n int) string { - if n == 1 { - return "" - } - return "s" -} diff --git a/internal/ui/msgs.go b/internal/ui/msgs.go new file mode 100644 index 0000000..9d0aaed --- /dev/null +++ b/internal/ui/msgs.go @@ -0,0 +1,17 @@ +package ui + +import forkcleaner "github.com/caarlos0/fork-cleaner/v2" + +type errMsg struct{ error } + +func (e errMsg) Error() string { return e.error.Error() } + +type getRepoListMsg struct{} + +type gotRepoListMsg struct { + repos []*forkcleaner.RepositoryWithDetails +} + +type reposDeletedMsg struct{} + +type requestDeleteSelectedReposMsg struct{} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..8b205cb --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,20 @@ +package ui + +import "github.com/charmbracelet/lipgloss" + +var ( + errorColor = lipgloss.AdaptiveColor{ + Light: "#e94560", + Dark: "#f05945", + } + listStyle = lipgloss.NewStyle().Margin(2) + detailsStyle = lipgloss.NewStyle().PaddingLeft(2) + + errorStyle = lipgloss.NewStyle().Foreground(errorColor) +) + +const ( + iconSelected = "●" + iconNotSelected = "○" + separator = " • " +)