-
Notifications
You must be signed in to change notification settings - Fork 141
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: browse local repositories (#369)
* feat: browse local repositories * refactor(ui): resolve race conditions and clean up code * feat(ui): update readme based on selected reference * feat(ui): add branch/tag commit date and hash Fixes: #382 * fix(ui): clean up statusbar logic * fix(ui): cleanup statusbar and misc msgs * fix(ui): preserve header line when no description is available * fix(ui): match readme and list missing items styles * fix(ui): header height calculation Fixes: 43b4331 (fix(ui): preserve header line when no description is available) * fix(ui): truncate refitem msg Fixes: fd02409 (feat(ui): add branch/tag commit date and hash) * fix(ui): dry code line number and highlight formatting * feat(ui): add blame file view Fixes: #149 * fix: lint errors * fix(ui): NaN floats, analyse file content, and start spinner * feat(ui): use right mouse click to go back in the files tab * fix(ui): display "1" when there's only one page to display * feat(ui): add stash view Display repository stashed items * feat: run browse on "soft"
- Loading branch information
1 parent
5df79ee
commit 02e1617
Showing
36 changed files
with
1,844 additions
and
798 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,301 @@ | ||
package main | ||
|
||
import ( | ||
"fmt" | ||
"path/filepath" | ||
"time" | ||
|
||
"github.com/charmbracelet/bubbles/key" | ||
tea "github.com/charmbracelet/bubbletea" | ||
"github.com/charmbracelet/lipgloss" | ||
"github.com/charmbracelet/soft-serve/git" | ||
"github.com/charmbracelet/soft-serve/server/proto" | ||
"github.com/charmbracelet/soft-serve/server/ui/common" | ||
"github.com/charmbracelet/soft-serve/server/ui/components/footer" | ||
"github.com/charmbracelet/soft-serve/server/ui/pages/repo" | ||
"github.com/muesli/termenv" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
var browseCmd = &cobra.Command{ | ||
Use: "browse PATH", | ||
Short: "Browse a repository", | ||
Args: cobra.MaximumNArgs(1), | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
rp := "." | ||
if len(args) > 0 { | ||
rp = args[0] | ||
} | ||
|
||
abs, err := filepath.Abs(rp) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
r, err := git.Open(abs) | ||
if err != nil { | ||
return fmt.Errorf("failed to open repository: %w", err) | ||
} | ||
|
||
// Bubble Tea uses Termenv default output so we have to use the same | ||
// thing here. | ||
output := termenv.DefaultOutput() | ||
ctx := cmd.Context() | ||
c := common.NewCommon(ctx, output, 0, 0) | ||
comps := []common.TabComponent{ | ||
repo.NewReadme(c), | ||
repo.NewFiles(c), | ||
repo.NewLog(c), | ||
} | ||
if !r.IsBare { | ||
comps = append(comps, repo.NewStash(c)) | ||
} | ||
comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags)) | ||
m := &model{ | ||
model: repo.New(c, comps...), | ||
repo: repository{r}, | ||
common: c, | ||
} | ||
|
||
m.footer = footer.New(c, m) | ||
p := tea.NewProgram(m, | ||
tea.WithAltScreen(), | ||
tea.WithMouseCellMotion(), | ||
) | ||
|
||
_, err = p.Run() | ||
return err | ||
}, | ||
} | ||
|
||
func init() { | ||
// HACK: This is a hack to hide the clone url | ||
// TODO: Make this configurable | ||
common.CloneCmd = func(publicURL, name string) string { return "" } | ||
rootCmd.AddCommand(browseCmd) | ||
} | ||
|
||
type state int | ||
|
||
const ( | ||
startState state = iota | ||
errorState | ||
) | ||
|
||
type model struct { | ||
model *repo.Repo | ||
footer *footer.Footer | ||
repo proto.Repository | ||
common common.Common | ||
state state | ||
showFooter bool | ||
error error | ||
} | ||
|
||
var _ tea.Model = &model{} | ||
|
||
func (m *model) SetSize(w, h int) { | ||
m.common.SetSize(w, h) | ||
style := m.common.Styles.App.Copy() | ||
wm := style.GetHorizontalFrameSize() | ||
hm := style.GetVerticalFrameSize() | ||
if m.showFooter { | ||
hm += m.footer.Height() | ||
} | ||
|
||
m.footer.SetSize(w-wm, h-hm) | ||
m.model.SetSize(w-wm, h-hm) | ||
} | ||
|
||
// ShortHelp implements help.KeyMap. | ||
func (m model) ShortHelp() []key.Binding { | ||
switch m.state { | ||
case errorState: | ||
return []key.Binding{ | ||
m.common.KeyMap.Back, | ||
m.common.KeyMap.Quit, | ||
m.common.KeyMap.Help, | ||
} | ||
default: | ||
return m.model.ShortHelp() | ||
} | ||
} | ||
|
||
// FullHelp implements help.KeyMap. | ||
func (m model) FullHelp() [][]key.Binding { | ||
switch m.state { | ||
case errorState: | ||
return [][]key.Binding{ | ||
{ | ||
m.common.KeyMap.Back, | ||
}, | ||
{ | ||
m.common.KeyMap.Quit, | ||
m.common.KeyMap.Help, | ||
}, | ||
} | ||
default: | ||
return m.model.FullHelp() | ||
} | ||
} | ||
|
||
// Init implements tea.Model. | ||
func (m *model) Init() tea.Cmd { | ||
return tea.Batch( | ||
m.model.Init(), | ||
m.footer.Init(), | ||
func() tea.Msg { | ||
return repo.RepoMsg(m.repo) | ||
}, | ||
repo.UpdateRefCmd(m.repo), | ||
) | ||
} | ||
|
||
// Update implements tea.Model. | ||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
m.common.Logger.Debugf("msg received: %T", msg) | ||
cmds := make([]tea.Cmd, 0) | ||
switch msg := msg.(type) { | ||
case tea.WindowSizeMsg: | ||
m.SetSize(msg.Width, msg.Height) | ||
case tea.KeyMsg: | ||
switch { | ||
case key.Matches(msg, m.common.KeyMap.Back) && m.error != nil: | ||
m.error = nil | ||
m.state = startState | ||
// Always show the footer on error. | ||
m.showFooter = m.footer.ShowAll() | ||
case key.Matches(msg, m.common.KeyMap.Help): | ||
cmds = append(cmds, footer.ToggleFooterCmd) | ||
case key.Matches(msg, m.common.KeyMap.Quit): | ||
// Stop bubblezone background workers. | ||
m.common.Zone.Close() | ||
return m, tea.Quit | ||
} | ||
case tea.MouseMsg: | ||
switch msg.Type { | ||
case tea.MouseLeft: | ||
switch { | ||
case m.common.Zone.Get("footer").InBounds(msg): | ||
cmds = append(cmds, footer.ToggleFooterCmd) | ||
} | ||
} | ||
case footer.ToggleFooterMsg: | ||
m.footer.SetShowAll(!m.footer.ShowAll()) | ||
m.showFooter = !m.showFooter | ||
case common.ErrorMsg: | ||
m.error = msg | ||
m.state = errorState | ||
m.showFooter = true | ||
} | ||
|
||
f, cmd := m.footer.Update(msg) | ||
m.footer = f.(*footer.Footer) | ||
if cmd != nil { | ||
cmds = append(cmds, cmd) | ||
} | ||
|
||
r, cmd := m.model.Update(msg) | ||
m.model = r.(*repo.Repo) | ||
if cmd != nil { | ||
cmds = append(cmds, cmd) | ||
} | ||
|
||
// This fixes determining the height margin of the footer. | ||
m.SetSize(m.common.Width, m.common.Height) | ||
|
||
return m, tea.Batch(cmds...) | ||
} | ||
|
||
// View implements tea.Model. | ||
func (m *model) View() string { | ||
style := m.common.Styles.App.Copy() | ||
wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize() | ||
if m.showFooter { | ||
hm += m.footer.Height() | ||
} | ||
|
||
var view string | ||
switch m.state { | ||
case startState: | ||
view = m.model.View() | ||
case errorState: | ||
err := m.common.Styles.ErrorTitle.Render("Bummer") | ||
err += m.common.Styles.ErrorBody.Render(m.error.Error()) | ||
view = m.common.Styles.Error.Copy(). | ||
Width(m.common.Width - | ||
wm - | ||
m.common.Styles.ErrorBody.GetHorizontalFrameSize()). | ||
Height(m.common.Height - | ||
hm - | ||
m.common.Styles.Error.GetVerticalFrameSize()). | ||
Render(err) | ||
} | ||
|
||
if m.showFooter { | ||
view = lipgloss.JoinVertical(lipgloss.Top, view, m.footer.View()) | ||
} | ||
|
||
return m.common.Zone.Scan(style.Render(view)) | ||
} | ||
|
||
type repository struct { | ||
r *git.Repository | ||
} | ||
|
||
var _ proto.Repository = repository{} | ||
|
||
// Description implements proto.Repository. | ||
func (r repository) Description() string { | ||
return "" | ||
} | ||
|
||
// ID implements proto.Repository. | ||
func (r repository) ID() int64 { | ||
return 0 | ||
} | ||
|
||
// IsHidden implements proto.Repository. | ||
func (repository) IsHidden() bool { | ||
return false | ||
} | ||
|
||
// IsMirror implements proto.Repository. | ||
func (repository) IsMirror() bool { | ||
return false | ||
} | ||
|
||
// IsPrivate implements proto.Repository. | ||
func (repository) IsPrivate() bool { | ||
return false | ||
} | ||
|
||
// Name implements proto.Repository. | ||
func (r repository) Name() string { | ||
return filepath.Base(r.r.Path) | ||
} | ||
|
||
// Open implements proto.Repository. | ||
func (r repository) Open() (*git.Repository, error) { | ||
return r.r, nil | ||
} | ||
|
||
// ProjectName implements proto.Repository. | ||
func (r repository) ProjectName() string { | ||
return r.Name() | ||
} | ||
|
||
// UpdatedAt implements proto.Repository. | ||
func (r repository) UpdatedAt() time.Time { | ||
t, err := r.r.LatestCommitTime() | ||
if err != nil { | ||
return time.Time{} | ||
} | ||
|
||
return t | ||
} | ||
|
||
// UserID implements proto.Repository. | ||
func (r repository) UserID() int64 { | ||
return 0 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.