feat: add files content line number

Ayman Bagabas created

Change summary

ui/components/code/code.go | 43 +++++++++++++++++++++++++++++++++++++++
ui/pages/repo/files.go     | 16 ++++++++++++++
2 files changed, 58 insertions(+), 1 deletion(-)

Detailed changes

ui/components/code/code.go 🔗

@@ -1,6 +1,7 @@
 package code
 
 import (
+	"fmt"
 	"strings"
 	"sync"
 
@@ -15,6 +16,11 @@ import (
 	"github.com/muesli/termenv"
 )
 
+var (
+	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+)
+
 // Code is a code snippet.
 type Code struct {
 	*vp.Viewport
@@ -24,7 +30,11 @@ type Code struct {
 	renderContext  gansi.RenderContext
 	renderMutex    sync.Mutex
 	styleConfig    gansi.StyleConfig
+	showLineNumber bool
+
 	NoContentStyle lipgloss.Style
+	LineDigitStyle lipgloss.Style
+	LineBarStyle   lipgloss.Style
 }
 
 // New returns a new Code.
@@ -35,6 +45,8 @@ func New(c common.Common, content, extension string) *Code {
 		extension:      extension,
 		Viewport:       vp.New(c),
 		NoContentStyle: c.Styles.CodeNoContent.Copy(),
+		LineDigitStyle: lineDigitStyle,
+		LineBarStyle:   lineBarStyle,
 	}
 	st := common.StyleConfig()
 	r.styleConfig = st
@@ -46,6 +58,11 @@ func New(c common.Common, content, extension string) *Code {
 	return r
 }
 
+// SetShowLineNumber sets whether to show line numbers.
+func (r *Code) SetShowLineNumber(show bool) {
+	r.showLineNumber = show
+}
+
 // SetSize implements common.Component.
 func (r *Code) SetSize(width, height int) {
 	r.common.SetSize(width, height)
@@ -64,12 +81,16 @@ func (r *Code) Init() tea.Cmd {
 	w := r.common.Width
 	c := r.content
 	if c == "" {
-		c = r.NoContentStyle.String()
+		r.Viewport.Model.SetContent(r.NoContentStyle.String())
+		return nil
 	}
 	f, err := r.renderFile(r.extension, c, w)
 	if err != nil {
 		return common.ErrorCmd(err)
 	}
+	if r.showLineNumber {
+		f = withLineNumber(f)
+	}
 	// FIXME: this is a hack to reset formatting at the end of every line.
 	c = wrap.String(f, w)
 	s := strings.Split(c, "\n")
@@ -196,3 +217,23 @@ func (r *Code) renderFile(path, content string, width int) (string, error) {
 	}
 	return s.String(), nil
 }
+
+func withLineNumber(s string) string {
+	lines := strings.Split(s, "\n")
+	// NB: len() is not a particularly safe way to count string width (because
+	// it's counting bytes instead of runes) but in this case it's okay
+	// because we're only dealing with digits, which are one byte each.
+	mll := len(fmt.Sprintf("%d", len(lines)))
+	for i, l := range lines {
+		digit := fmt.Sprintf("%*d", mll, i+1)
+		bar := "│"
+		digit = lineDigitStyle.Render(digit)
+		bar = lineBarStyle.Render(bar)
+		if i < len(lines)-1 || len(l) != 0 {
+			// If the final line was a newline we'll get an empty string for
+			// the final line, so drop the newline altogether.
+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+		}
+	}
+	return strings.Join(lines, "\n")
+}

ui/pages/repo/files.go 🔗

@@ -28,6 +28,13 @@ var (
 	errInvalidFile    = errors.New("invalid file")
 )
 
+var (
+	lineNo = key.NewBinding(
+		key.WithKeys("l"),
+		key.WithHelp("l", "toggle line numbers"),
+	)
+)
+
 // FileItemsMsg is a message that contains a list of files.
 type FileItemsMsg []selector.IdentifiableItem
 
@@ -49,6 +56,7 @@ type Files struct {
 	currentItem    *FileItem
 	currentContent FileContentMsg
 	lastSelected   []int
+	lineNumber     bool
 }
 
 // NewFiles creates a new files model.
@@ -58,6 +66,7 @@ func NewFiles(common common.Common) *Files {
 		code:         code.New(common, "", ""),
 		activeView:   filesViewFiles,
 		lastSelected: make([]int, 0),
+		lineNumber:   true,
 	}
 	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common})
 	selector.SetShowFilter(false)
@@ -70,6 +79,7 @@ func NewFiles(common common.Common) *Files {
 	selector.KeyMap.NextPage = common.KeyMap.NextPage
 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 	f.selector = selector
+	f.code.SetShowLineNumber(f.lineNumber)
 	return f
 }
 
@@ -101,6 +111,7 @@ func (f *Files) ShortHelp() []key.Binding {
 			f.common.KeyMap.UpDown,
 			f.common.KeyMap.BackItem,
 			copyKey,
+			lineNo,
 		}
 	default:
 		return []key.Binding{}
@@ -158,6 +169,7 @@ func (f *Files) FullHelp() [][]key.Binding {
 			},
 			{
 				copyKey,
+				lineNo,
 			},
 		}...)
 	}
@@ -222,6 +234,10 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, f.deselectItemCmd)
 			case key.Matches(msg, f.common.KeyMap.Copy):
 				f.common.Copy.Copy(f.currentContent.content)
+			case key.Matches(msg, lineNo):
+				f.lineNumber = !f.lineNumber
+				f.code.SetShowLineNumber(f.lineNumber)
+				cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
 			}
 		}
 	case tea.WindowSizeMsg: