diff --git a/README.md b/README.md index 01f6f79..14afb38 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,10 @@ auto_continue: false # Auto-accept env vars without prompting # environment variables and shell history for a value to insert) key_substitute: "ctrl+t" substitute_sources: ["env", "history"] # set to [] to disable + +# Markdown preview (press Ctrl-Y to open the current cheat's source file +# rendered as markdown, scrolled to the cheat's section) +key_preview: "ctrl+y" ``` ### Substitute search @@ -160,6 +164,14 @@ Pick a row, its value is loaded into the prompt; press `Enter` to accept or edit it first. `Esc` cancels back to the var prompt. History is read from `$HISTFILE`, falling back to `~/.bash_history` or `~/.zsh_history`. +### Markdown preview + +Press `Ctrl-Y` at any cheat (from the picker or while resolving variables) to +open the cheat's source file rendered as markdown in a full-screen overlay, +auto-scrolled to the cheat's heading. `↑/↓`/`PgUp/PgDn` scroll, `Esc` or `q` +returns. Useful for reading the surrounding notes (descriptions, links, +warnings) without leaving the TUI. + ## DSL ``` diff --git a/cheatmd.example.yaml b/cheatmd.example.yaml index c40a5ee..16a44fb 100644 --- a/cheatmd.example.yaml +++ b/cheatmd.example.yaml @@ -18,6 +18,9 @@ key_open: "ctrl+o" # key_substitute: opens a fuzzy search of env vars + shell history during # variable resolution, lets you pick a value to substitute into the prompt. key_substitute: "ctrl+t" +# key_preview: opens a full-screen markdown preview of the current cheat's +# source file, scrolled to the cheat's section. Esc / q closes. +key_preview: "ctrl+y" # Substitute search sources. Valid entries: "env", "history". # Empty list disables the feature. diff --git a/go.mod b/go.mod index 5c345ff..e0b2773 100644 --- a/go.mod +++ b/go.mod @@ -3,30 +3,41 @@ module github.com/gubarz/cheatmd go 1.26.3 require ( - github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - 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/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 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.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -37,7 +48,11 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect ) diff --git a/go.sum b/go.sum index e91a1cc..b9cf582 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,52 @@ +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= 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= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -29,6 +57,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -37,22 +67,32 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -80,16 +120,27 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 6c57183..591f062 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type Config struct { KeyWidget string `mapstructure:"key_widget"` KeyOpen string `mapstructure:"key_open"` KeySubstitute string `mapstructure:"key_substitute"` + KeyPreview string `mapstructure:"key_preview"` // Substitute search SubstituteSources []string `mapstructure:"substitute_sources"` @@ -86,6 +87,7 @@ var defaults = struct { keyWidget string keyOpen string keySubstitute string + keyPreview string substituteSources []string showFolder bool showFile bool @@ -107,6 +109,7 @@ var defaults = struct { keyWidget: "\\C-g", // Ctrl+G for shell widgets keyOpen: "ctrl+o", // Ctrl+O in TUI keySubstitute: "ctrl+t", // Ctrl+T opens substitute search during var resolution + keyPreview: "ctrl+y", // Ctrl+Y opens markdown preview of current cheat's file substituteSources: []string{"env", "history"}, showFolder: true, showFile: true, @@ -173,6 +176,7 @@ func setDefaults() { viper.SetDefault("key_widget", defaults.keyWidget) viper.SetDefault("key_open", defaults.keyOpen) viper.SetDefault("key_substitute", defaults.keySubstitute) + viper.SetDefault("key_preview", defaults.keyPreview) // Substitute search viper.SetDefault("substitute_sources", defaults.substituteSources) @@ -320,6 +324,12 @@ func GetKeySubstitute() string { return viper.GetString("key_substitute") } +// GetKeyPreview returns the keybinding for opening the markdown preview of +// the current cheat's source file (e.g., "ctrl+y"). +func GetKeyPreview() string { + return viper.GetString("key_preview") +} + // GetSubstituteSources returns the enabled sources for substitute search. // Valid entries: "env", "history". Empty disables the feature. func GetSubstituteSources() []string { diff --git a/internal/ui/cheat_select.go b/internal/ui/cheat_select.go index 94dbc21..3faa639 100644 --- a/internal/ui/cheat_select.go +++ b/internal/ui/cheat_select.go @@ -212,6 +212,13 @@ func (m *mainModel) handleCheatSelectKey(msg tea.KeyMsg) tea.Cmd { openFileInViewer(m.filtered[m.cursor].cheat.File) } } + if msg.String() == config.GetKeyPreview() { + if m.cursor < len(m.filtered) { + if m.enterPreview(m.filtered[m.cursor].cheat) { + return tea.ClearScreen + } + } + } } return nil } diff --git a/internal/ui/main_model.go b/internal/ui/main_model.go index 61dc0a9..6b69461 100644 --- a/internal/ui/main_model.go +++ b/internal/ui/main_model.go @@ -51,6 +51,7 @@ const ( phaseCheatSelect uiPhase = iota // Selecting a cheat phaseVarResolve // Resolving variables phaseSubstituteSearch // Substitute-search overlay during var resolution + phasePreview // Full-screen markdown preview of cheat's source file ) // mainModel is the Bubble Tea model for cheat selection AND variable resolution @@ -80,6 +81,9 @@ type mainModel struct { // Substitute search state (only used in phaseSubstituteSearch) subState *substituteSearchState + // Preview overlay state (only used in phasePreview) + previewState *previewOverlayState + // Dependencies for variable resolution cheatIndex *parser.CheatIndex executor Executor @@ -148,6 +152,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Dispatch based on phase switch m.phase { + case phasePreview: + return m.updatePreview(msg) case phaseSubstituteSearch: return m.updateSubstituteSearch(msg) case phaseVarResolve: @@ -166,6 +172,8 @@ func (m *mainModel) View() string { // Dispatch based on phase switch m.phase { + case phasePreview: + return m.renderPreview() case phaseSubstituteSearch: return m.renderSubstituteSearch() case phaseVarResolve: diff --git a/internal/ui/preview.go b/internal/ui/preview.go new file mode 100644 index 0000000..b88b01e --- /dev/null +++ b/internal/ui/preview.go @@ -0,0 +1,303 @@ +package ui + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + + "github.com/gubarz/cheatmd/internal/config" + "github.com/gubarz/cheatmd/internal/parser" +) + +// previewOverlayState holds the state for the markdown preview overlay. +// The user enters via the configured key during phaseCheatSelect or +// phaseVarResolve and returns to the previous phase on Esc. +type previewOverlayState struct { + viewport viewport.Model + cheat *parser.Cheat // cheat whose file is being shown + prevPhase uiPhase // phase to restore on exit + errorMsg string // non-empty if rendering or reading failed +} + +// enterPreview transitions to phasePreview with the markdown rendering of the +// given cheat's source file. Returns true on success, false if no cheat is +// available (caller should remain in current phase). +func (m *mainModel) enterPreview(c *parser.Cheat) bool { + if c == nil || c.File == "" { + return false + } + + // Read the entire source file. + data, err := os.ReadFile(c.File) + if err != nil { + m.previewState = &previewOverlayState{ + cheat: c, + prevPhase: m.phase, + errorMsg: fmt.Sprintf("Could not read %s: %v", c.File, err), + } + m.previewState.viewport = newPreviewViewport(m.width, m.height) + m.previewState.viewport.SetContent(m.previewState.errorMsg) + m.phase = phasePreview + return true + } + + vp := newPreviewViewport(m.width, m.height) + rendered, err := renderMarkdown(string(data), vp.Width) + if err != nil { + // Fall back to the raw source on render failure. + rendered = string(data) + } + vp.SetContent(rendered) + + // Scroll so the cheat's header is near the top of the viewport. + if line := findHeaderLine(rendered, c.Header); line >= 0 { + vp.SetYOffset(line) + } + + m.previewState = &previewOverlayState{ + viewport: vp, + cheat: c, + prevPhase: m.phase, + } + m.phase = phasePreview + return true +} + +// exitPreview returns to whichever phase was active when preview was entered. +func (m *mainModel) exitPreview() { + if m.previewState == nil { + m.phase = phaseCheatSelect + return + } + m.phase = m.previewState.prevPhase + m.previewState = nil +} + +// updatePreview handles updates while the preview overlay is open. +func (m *mainModel) updatePreview(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.previewState != nil { + m.previewState.viewport.Width = max(msg.Width, 1) + m.previewState.viewport.Height = max(msg.Height-1, 1) // 1 row for hint + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.quitting = true + m.selected = nil + return m, tea.Quit + case "esc", "q": + m.exitPreview() + return m, tea.ClearScreen + } + } + + if m.previewState != nil { + var cmd tea.Cmd + m.previewState.viewport, cmd = m.previewState.viewport.Update(msg) + return m, cmd + } + return m, nil +} + +// renderPreview renders the preview overlay. +func (m *mainModel) renderPreview() string { + if m.previewState == nil { + return "" + } + b := getBuilder() + defer putBuilder(b) + + b.WriteString(m.previewState.viewport.View()) + b.WriteString("\n") + b.WriteString(styles.Dim.Render(" ESC close")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("↑/↓ scroll")) + b.WriteString(" • ") + b.WriteString(styles.Dim.Render("PgUp/PgDn page")) + return b.String() +} + +// newPreviewViewport creates a viewport sized to the terminal, reserving one +// row at the bottom for the hint line. +func newPreviewViewport(width, height int) viewport.Model { + w := max(width, 40) + h := max(height-1, 5) + vp := viewport.New(w, h) + return vp +} + +// renderMarkdown returns the glamour-rendered markdown for raw at the given +// terminal width. Uses a custom style configured from cheatmd's color palette +// so the preview matches the rest of the TUI. +func renderMarkdown(raw string, width int) (string, error) { + r, err := glamour.NewTermRenderer( + glamour.WithStyles(cheatmdGlamourStyle()), + glamour.WithWordWrap(max(width-4, 40)), + ) + if err != nil { + return "", err + } + return r.Render(raw) +} + +// cheatmdGlamourStyle returns an ansi.StyleConfig that maps glamour's style +// slots to cheatmd's configured color palette (color_header, color_command, +// color_path, color_border, color_desc, color_dim). Called once per preview +// open so live config edits take effect on the next preview. +func cheatmdGlamourStyle() ansi.StyleConfig { + header := config.GetColorHeader() + command := config.GetColorCommand() + desc := config.GetColorDesc() + path := config.GetColorPath() + border := config.GetColorBorder() + dim := config.GetColorDim() + + str := func(s string) *string { return &s } + b := func(v bool) *bool { return &v } + u := func(v uint) *uint { return &v } + + margin := uint(2) + listIndent := uint(2) + + return ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockPrefix: "\n", + BlockSuffix: "\n", + Color: str(desc), + }, + Margin: u(margin), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Color: str(dim)}, + Indent: u(1), + IndentToken: str("│ "), + }, + List: ansi.StyleList{ + LevelIndent: listIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Color: str(header), + Bold: b(true), + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: str(header), + Bold: b(true), + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: str(header), + Bold: b(true), + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: str(header), + Bold: b(true), + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: str(header), + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: str(header), + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: str(dim), + }, + }, + Strikethrough: ansi.StylePrimitive{CrossedOut: b(true)}, + Emph: ansi.StylePrimitive{Italic: b(true), Color: str(desc)}, + Strong: ansi.StylePrimitive{Bold: b(true), Color: str(desc)}, + HorizontalRule: ansi.StylePrimitive{ + Color: str(border), + Format: "\n────────\n", + }, + Item: ansi.StylePrimitive{BlockPrefix: "• "}, + Enumeration: ansi.StylePrimitive{BlockPrefix: ". "}, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{}, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Color: str(path), + Underline: b(true), + }, + LinkText: ansi.StylePrimitive{ + Color: str(path), + Bold: b(true), + }, + Image: ansi.StylePrimitive{ + Color: str(path), + Underline: b(true), + }, + ImageText: ansi.StylePrimitive{ + Color: str(dim), + Format: "Image: {{.text}} →", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: str(command), + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{Color: str(command)}, + Margin: u(margin), + }, + // Letting Chroma stay nil makes glamour render code blocks as + // flat-colored text in our command color, which keeps the + // preview visually consistent with the rest of the TUI. + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{StylePrimitive: ansi.StylePrimitive{}}, + }, + DefinitionDescription: ansi.StylePrimitive{BlockPrefix: "\n→ "}, + } +} + +// findHeaderLine returns the line number in rendered where the given heading +// text first appears, or -1 if not found. Used to scroll the viewport so the +// cheat's section is visible on open. +func findHeaderLine(rendered, header string) int { + if header == "" { + return -1 + } + needle := strings.TrimSpace(header) + if needle == "" { + return -1 + } + for i, line := range strings.Split(rendered, "\n") { + if strings.Contains(line, needle) { + return i + } + } + return -1 +} diff --git a/internal/ui/var_resolve.go b/internal/ui/var_resolve.go index a609bb9..73b2c76 100644 --- a/internal/ui/var_resolve.go +++ b/internal/ui/var_resolve.go @@ -399,6 +399,13 @@ func (m *mainModel) handleVarResolveKey(msg tea.KeyMsg) tea.Cmd { return tea.Batch(tea.ClearScreen, textinput.Blink) } } + if msg.String() == config.GetKeyPreview() { + if m.varState != nil && m.varState.cheat != nil { + if m.enterPreview(m.varState.cheat) { + return tea.ClearScreen + } + } + } } return nil }