diff --git a/go.mod b/go.mod index 72a8f66..633d200 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module vcom go 1.26.0 require ( + github.com/alecthomas/chroma/v2 v2.23.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -17,6 +18,7 @@ require ( github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index bb04a3e..36232f6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= 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= @@ -16,6 +18,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/fs/preview.go b/internal/fs/preview.go index 9bc062c..e54f139 100644 --- a/internal/fs/preview.go +++ b/internal/fs/preview.go @@ -11,6 +11,11 @@ import ( "os" "path/filepath" "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" ) type PreviewKind string @@ -38,10 +43,11 @@ type Metadata struct { } type Preview struct { - Kind PreviewKind - Title string - Body string - Metadata Metadata + Kind PreviewKind + Title string + Body string + PlainBody string + Metadata Metadata } type PreviewOptions struct { @@ -52,6 +58,7 @@ type PreviewOptions struct { MaxPreviewBytes int64 DirectoryPreviewLimit int HumanReadableSize bool + ThemeName string } func BuildPreview(entry Entry, options PreviewOptions) Preview { @@ -77,6 +84,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview { preview.Metadata.Size = entry.Size preview.Metadata.SizeKnown = entry.DirSizeKnown preview.Body = buildDirectoryPreview(entry.Path, options) + preview.PlainBody = preview.Body return preview } @@ -87,6 +95,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview { if err != nil { preview.Kind = PreviewKindError preview.Body = fmt.Sprintf("Could not open file:\n\n%s", err) + preview.PlainBody = preview.Body return preview } defer file.Close() @@ -95,6 +104,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview { if _, err := io.CopyN(buffer, file, options.MaxPreviewBytes); err != nil && err != io.EOF { preview.Kind = PreviewKindError preview.Body = fmt.Sprintf("Could not read preview:\n\n%s", err) + preview.PlainBody = preview.Body return preview } @@ -109,20 +119,64 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview { dimensions, entry.Path, ) + preview.PlainBody = preview.Body return preview } if IsBinarySample(data) { preview.Kind = PreviewKindBinary preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type." + preview.PlainBody = preview.Body return preview } preview.Kind = PreviewKindText - preview.Body = strings.ReplaceAll(string(data), "\t", " ") + preview.PlainBody = strings.ReplaceAll(string(data), "\t", " ") + preview.Body = highlightText(entry.Path, preview.PlainBody, options.ThemeName) return preview } +func highlightText(path string, source string, themeName string) string { + lexer := lexers.Match(path) + if lexer == nil { + lexer = lexers.Analyse(source) + } + if lexer == nil { + return source + } + + iterator, err := chroma.Coalesce(lexer).Tokenise(nil, source) + if err != nil { + return source + } + + style := styles.Get(chromaStyleName(themeName)) + if style == nil { + return source + } + + var output bytes.Buffer + if err := formatters.TTY16m.Format(&output, style, iterator); err != nil { + return source + } + return output.String() +} + +func chromaStyleName(themeName string) string { + switch strings.ToLower(strings.TrimSpace(themeName)) { + case "catppuccin-mocha": + return "catppuccin-mocha" + case "tokyo-night": + return "tokyonight-night" + case "gruvbox-dark": + return "gruvbox" + case "nord-frost": + return "nord" + default: + return "catppuccin-mocha" + } +} + func buildDirectoryPreview(path string, options PreviewOptions) string { entries, err := ListDir(path, ListOptions{ ShowHidden: options.ShowHidden, diff --git a/internal/ui/model.go b/internal/ui/model.go index 7bbfa82..257099c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -513,6 +513,7 @@ func (m Model) loadPreviewCmd() tea.Cmd { MaxPreviewBytes: m.cfg.Preview.MaxPreviewBytes, DirectoryPreviewLimit: m.cfg.Preview.DirectoryPreviewLimit, HumanReadableSize: m.cfg.Browser.HumanReadableSize, + ThemeName: m.cfg.UI.Theme, } return func() tea.Msg { @@ -952,7 +953,10 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c } func renderSelectionPane(preview vfs.Preview, viewportModel *viewport.Model, palette theme.Palette, width int, height int) string { - content := preview.Body + content := preview.PlainBody + if strings.TrimSpace(content) == "" { + content = preview.Body + } viewportModel.Width = max(width, 1) viewportModel.Height = max(height, 1) viewportModel.SetContent(content)